zoukankan      html  css  js  c++  java
  • 用NodeJS打造你的静态文件服务器(下)

    但是,貌似我们有提到gzip这样的东西。对于CSS,JS等文件如果不采用gzip的话,还是会浪费掉部分网络带宽。那么接下来把gzip搞起吧。

    GZip启用

    如果你是前端达人,你应该是知道YUI Compressor或Google Closure Complier这样的压缩工具的。在这基础上,再进行gzip压缩,则会减少很多的网络流量。那么,我们看看Node中,怎么把gzip搞起类。

    要用到gzip,就需要zlib模块,该模块在Node的0.5.8版本开始原生支持。

    1
    var zlib = require("zlib");

    对于图片一类的文件,不需要进行gzip压缩,所以我们在config.js中配置一个启用压缩的列表。

    1
    2
    3
    exports.Compress = {
        match: /css|js|html/ig
    };

    这里为了防止大文件,也为了满足zlib模块的调用模式,将读取文件改为流的形式进行读取。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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的话,可以监听一下请求,会看到被压缩的请求。

    最终app.js文件的代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    var server = http.createServer(function(request, response) {
        var pathname = url.parse(request.url).pathname;
        var realPath = path.join("assets", pathname);
     
        path.exists(realPath, function (exists) {
            if (!exists) {
                response.writeHead(404, "Not Found", {'Content-Type''text/plain'});
                response.write("This request URL " + pathname + " was not found on this server.");
                response.end();
            else {
                var ext = path.extname(realPath);
                ext = ext ? ext.slice(1) : 'unknown';
                var contentType = mime[ext] || "text/plain";
                response.setHeader("Content-Type", contentType);
     
                fs.stat(realPath, function (err, stat) {
                    var lastModified = stat.mtime.toUTCString();
                    var ifModifiedSince = "If-Modified-Since".toLowerCase();
                    response.setHeader("Last-Modified", lastModified);
     
                    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);
                    }
     
                    if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) {
                        response.writeHead(304, "Not Modified");
                        response.end();
                    else {
                        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);
                        }
                    }
                });
            }
        });
    });
    1
     

     

    安全问题

    我们搞了一大堆的事情,但是安全方面也不能少。想想哪一个地方是最容易出问题的? 我们发现上面的这段代码写得还是有点纠结的,通常这样纠结的代码我是不愿意拿出去让人看见的。但是,假如一个同学用浏览器访问http://localhost:8000/../app.js 怎么办捏? 不用太害怕,浏览器会自动干掉那两个作为父路径的点的。浏览器会把这个路径组装成http://localhost:8000/app.js的,这个文件在assets目录下不存在,返回404 Not Found。 但是文艺一点的同学会通过curl -ihttp://localhost:8000/../app.js 来访问。于是,悲剧了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    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方法来处理掉不正常的/。

    1
    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,再重新做解析。

    那么不喜欢hardcode的你,肯定是要把这个文件配置进config.js啦。这样你就可以选择各种后缀作为welcome页面。

    1
    2
    3
    exports.Welcome = {
        file: "index.html"
    };

    那么第一步,为/结尾的请求,自动添加上”index.html”。

    1
    2
    3
    if (pathname.slice(-1) === "/") {
        pathname = pathname + config.Welcome.file;
    }

    第二步,如果请求了一个目录路径,并且没有以/结尾。那么我们需要做判断。如果当前读取的路径是目录,就需要添加上/和index.html

    1
    2
    3
    if (stats.isDirectory()) {
        realPath = path.join(realPath, "/", config.Welcome.file);
    }

    由于我们目前的结构发生了一点点变化。所以需要重构一下函数。而且,fs.stat方法具有比fs.exsits方法更多的功能。我们直接替代掉它。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    var server = http.createServer(function(request, response) {
    var pathname = url.parse(request.url).pathname;
        if (pathname.slice(-1) === "/") {
            pathname = pathname + config.Welcome.file;
        }
        var realPath = path.join("assets", path.normalize(pathname.replace(/\.\./g,"")));
     
        var pathHandle = function (realPath) {
            fs.stat(realPath, function (err, stats) {
                if (err) {
                    response.writeHead(404, "Not Found", {'Content-Type''text/plain'});
                    response.write("This request URL " + pathname + " was not found on this server.");
                    response.end();
                else {
                    if (stats.isDirectory()) {
                        realPath = path.join(realPath, "/", config.Welcome.file);
                        pathHandle(realPath);
                    else {
                        var ext = path.extname(realPath);
                        ext = ext ? ext.slice(1) : 'unknown';
                        var contentType = mime[ext] || "text/plain";
                        response.setHeader("Content-Type", contentType);
     
                        var lastModified = stats.mtime.toUTCString();
                        var ifModifiedSince = "If-Modified-Since".toLowerCase();
                        response.setHeader("Last-Modified", lastModified);
     
                        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);
                        }
     
                        if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) {
                            response.writeHead(304, "Not Modified");
                            response.end();
                        else {
                            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);
                            }
                        }
                    }
                }
            });
        };
     
        pathHandle(realPath);
    });

    就这样。一个各方面都比较完整的静态文件服务器就这样打造完毕。

    Range支持,搞定媒体断点支持

    关于http1.1中的Range定义,可以参见这两篇文章:

    接下来,我将简单地介绍一下range的作用和其定义。

    当用户在听一首歌的时候,如果听到一半(网络下载了一半),网络断掉了,用户需要继续听的时候,文件服务器不支持断点的话,则用户需要重新下载这个文件。而Range支持的话,客户端应该记录了之前已经读取的文件范围,网络恢复之后,则向服务器发送读取剩余Range的请求,服务端只需要发送客户端请求的那部分内容,而不用整个文件发送回客户端,以此节省网络带宽。

    那么HTTP1.1规范的Range是怎样一个约定呢。

    1. 如果Server支持Range,首先就要告诉客户端,咱支持Range,之后客户端才可能发起带Range的请求。这里套用唐僧的一句话,你不说我怎么知道呢。
      response.setHeader(‘Accept-Ranges’, ‘bytes’);
    2. 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的请求头,则继续通过常规的方式响应。
    3. 有必要对Range请求做一下解释。
    1
    2
    3
    4
    5
    6
    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文件吧。

    1
    var utils = require("./utils");

    我们暂且不支持多区间吧。于是遇见逗号,就报416错误吧。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    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。

    1
    2
    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的。于是代码已经有点面条的味道了。重构一下吧。于是代码大致如此:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    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请求测试一番试试。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    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反向测试一下吧。

    1
    2
    3
    4
    5
    6
    7
    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支持完成,这个静态文件服务器支持一些流媒体文件,表示没有压力啦。

    后记

    由于本章的目的是完成一个纯静态的文件服务器,所以不需要涉及到cookie,session等动态服务器的特性。下一章会讲述如何打造一个动态服务器。

    最后再附赠一个小技巧。看到别人家的服务器都响应一个:

    1
    Server: nginx

    觉得老牛逼了。那么我们自己也搞一个吧。

    1
    response.setHeader("Server""Node/V5");

    嗯。就这么简单。

    全文的最终代码可以从这里下载: http://vdisk.weibo.com/s/15iUP

    项目目前已经发布到github上,同学们可以持续关注此项目的进展。github地址是:https://github.com/JacksonTian/nodev5

  • 相关阅读:
    SpringMVC中web.xml中url配置路径 / 和 /* 的区别
    spring mvc @RequestMapping method 不写的话,默认GET、POST都支持,根据前端方式自动适应
    MyBatis项目所引用的一切依赖jar包和自定义设置
    Python 代码性能优化技巧
    linux basic commands
    linuxx virutal machine installation
    安装 Django
    virtualenv (isolated virtual environment) for python runtime
    夜间模式的开启与关闭,父模板的制作
    完成登录与注册页面的前端
  • 原文地址:https://www.cnblogs.com/marryZhan/p/2268282.html
Copyright © 2011-2022 走看看