剑客
关注科技互联网

原生Node.js实现静态资源服务器

事起缘由:上个月面试的时候,面试官问我如何写静态文件服务器。额,这个,这个不是用Express里面的static方法一行命令就能搞掂的咩,要压缩的话一个Compress中间件就搞掂了呀?。。如果非要自己写的话,不就是createServer,监听一下路由,再读取文件输出文本流到客户端不就OK了吗?。。。。咦。。。稍等!稍等!!好像哪里不对,。。。读文件也要分文件类型来输出呀,image和视频文件就不能用文本格式输出,要考虑MIME呀;直接读文件的话,肯定还会涉及到安全性问题;读取I/O操作成本辣么高,不考虑缓存和Gzip压缩会死人的呀;如果是大文件的话,可能还要考虑断点续传耶;卧槽,如果要用于商业项目的话日志也是要考虑的呀,容错什么的都不能少耶。。。。。。。。我的天呀,静态文件服务器并没有我一开始想的那么简单,一头脑风暴下来就发现这东西注意的地方很多,看来确实值得研究和实操一遍呀。

好的,事不宜迟,开始写。

一个静态文件服务器需要具备什么素质呢?

首先,用户从客户端输入文件地址,服务器端根据这个地址进行解析,所以 第一步应该是解析路由 ;拿到路由之后就可以确定文件在磁盘的具体位置,就可以读取文件流,所以 第二步是读取文件流输出到客户端

如果不考虑性能优化、兼容性和监控管理的话,一个极其简单的静态资源服务器就算完成了。

但是,如果要追求完美的话,我们还需要做更多的事情,譬如:

  • MIME判断 ,区分文本文件和富媒体文件;
  • 增加缓存机制
  • 服务器端增加Gzip压缩
  • 大文件的断点续传
  • 日志监控
  • 安全性
  • 容错机制

好的,下面开始一步步实现。

一. 路由解析

let http = require("http"),
    url = require("url");

let staticPath = "./res/";

let app = http.createServer((request, response) => {
    let pathName = url.parse(request.url).pathname;

    response.write("pathName = " + pathName);
    response.end();
});

app.listen(80, "127.0.0.1");

此时,在客户端请求地址譬如 http:127.0.0.1/res/1.html ,客户端则会显示: pathName = /res/1.html

二. 将文件输出到客户端

拿到了路由接下来我们就可以根据静态文件夹的地址拼装出文件的真实地址,然后读取文件流并输出。

假设静态文件夹为 ./res/ 。设定用户输入 http:127.0.0.1/1.txt 时,服务器会将文件路径映射到 ./res/ 目录,即实际访问的是 ./res/1.txt 文件。

文件读取时,需要考虑文件是否存在和文件是否可读的情况,如果发生错误,应有响应信息和状态提示。

代码如下:

let http = require("http"),
    url = require("url"),
    path = require("path"),
    fs = require("fs");

let staticPath = "./res/";

let app = http.createServer((request, response) => {
    let pathName = url.parse(request.url).pathname,
        realPath = path.join(staticPath, pathName); // 请求文件的在磁盘中的真实地址

    fs.exists(realPath, (exists) => {
        if(!exists) {
            // 当文件不存在时
            response.writeHead(404, {"Content-Type": "text/plain"});

            response.write("This request URL ' " + realPath + " ' was not found on this server.");
            response.end();
        } else {
            // 当文件存在时
            fs.readFile(realPath, "binary", (err, file) => {
                if (err) {
                    // 文件读取出错
                    response.writeHead(500, {"Content-Type": "text/plain"});

                    response.end(err);
                } else {
                    // 当文件可被读取时,输出文本流
                    response.writeHead(200, {"Content-Type": "text/plain"});
                    response.write(file, "binary");
                    response.end();
                }
            });
        }
    });
});

app.listen(80, "127.0.0.1");

如果文件是文本文件的话,只要编码格式没啥问题,是可以正确读取的。但是如果访问的是富媒体资源如图片,就会出现下面这种尴尬的情形:

原生Node.js实现静态资源服务器

显然,png图片的MIME类型并不是 "text/html" ,而是”text/png”,如果非要以 "text/html" 格式读取的话很可能会出现乱码。

所以,我们的静态资源服务器中应该要增加MIME类型的支持。

三. MIME判断

不同文件对应着不同的MIME类型,所以应该首先有一张MIME类型的映射表。我们将文件后缀名对应的MIME值制作映射表,作为MIME模块。新建MIME.js文件,代码如下:

let type = {
    "txt": "text/plain",
    "xml": "text/xml",
    "html": "text/html",
    "css": "text/css",
    "js": "text/javascript",
    "json": "application/json",
    "gif": "image/gif",
    "png": "image/png",
    "jpeg": "image/jpeg",
    "jpg": "image/jpeg",
    "svg": "image/svg+xml",
    "ico": "image/x-icon",
    "pdf": "application/pdf",
    "swf": "application/x-shockwave-flash",
    "tiff": "image/tiff",
    "wav": "audio/x-wav",
    "wma": "audio/x-ms-wma",
    "wmv": "video/x-ms-wmv"
};

exports.type = type;

对于文件的后缀名,可以通过 path.extname 方法来获取。由于 path.extname 返回值中包含 . 。所以应除掉”.”。

let extName = path.extname(realPath);
extName = extName ? extName.slice(1) : "";

所以,文件真正的MIME类型就可以拿到了,如下:

// ...
let MIME = require("./MIME.js").type;

// ...
let contentType = MIME[extName] || "text/plain";
response.writeHead(200, {"Content-Type": contentType});

对于未知类型的文件,默认返回”text/plain”类型。

四. 增加缓存机制

如果文件每次访问都要进行一次I/O读写操作的话,当响应量一旦大起来的时候,硬盘可能会吃不消。

如果我们能充分利用好浏览器缓存的话,就能很好得挡掉很大一部分文件请求和读写操作。

在服务器端进行文件缓存处理的话,主要有四个Header属性需要配置,分别如下:

  • Expires / Cache-Control: max-age=xxxx :设置文件的最大缓存时间。
  • Last-Modified / If-Modified-Since :把浏览器端文件缓存的最后修改时间一起发到服务器去,服务器会把这个时间与服务器上实际文件的最后修改时间进行比较。如果没有修改,则返回304状态码。
  • ETag头 :用来判断文件是否已经被修改,区分不同语言和Session等等。

4.1 Expires / Cache-Control

配置缓存文件的最大缓存时长可通过设置Header中的Expires或Cache-Control属性来实现。如果没有过期,则不会向服务器端发送请求,而是直接从缓存中读取文件。

Cache-Control和Expires实现的功能一样。当Cache-Control和Expires同时定义时,Cache-Control会覆盖Expires属性。由于有些浏览器不支持Cache-Control而只支持Expires,因此还是有必要同时定义这两个属性的。

let Expires = {
    fileMatch: /^(gif|png|jpg|js|css)$/ig,
    maxAge: 60 * 60 * 24 * 365
};

// ...

if (extName.match(Expires.fileMatch)) {
    let expires = new Date();
    expires.setTime(expires.getTime() + Expires.maxAge * 1000);
    response.setHeader("Expires", expires.toUTCString());
    response.setHeader("Cache-Control", "max-age=" + Expires.maxAge);
}

4.2 Last-Modified / If-Modified-Since

读取文件的最后修改时间可通过fs.stat()方法来实现。

fs.stat(realPath, function (err, stat) {
    let 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();
}

4.3 ETag

浏览器在接受到服务器发过来的 Etag 后,会保存下来,下次请求的时候会将它放在请求头中,其 key 值为 If-None-Match

服务器拿到 If-None-Match 之后,对比之前的 Etag ,如果没变,则返回 304 Not Modified.

let crypto = require("crypto");

let hashStr = "A hash string.",
    hash = crypto.createHash('sha1').update(hashStr).digest('base64');

// ...

if(request.headers['if-none-match'] == hash){
    response.writeHead(304, "Not Modified");
    response.end();
} else {
    response.setHeader("Etag", hash);
    res.end(); 
}

Etag在缓存处理中用得比较广泛,使用它可以减少一些不必要请求。

对于静态文件,可以将文件最后一次的修改时间(Last-Modified)作为Etag值,这样就可以避免直接比较文件内容数据的hash值的消耗要小得多。所以很多时候ETag都是配合Last-Modified 一起使用。

五. Gzip压缩

对文本文件进行gzip压缩,可以将文件压缩得很小,大大减少网络流量。

为了满足zlib模块的调用模式,将读取文件改为流的形式。

对于支持压缩的文件格式以及浏览器端接受gzip或deflate压缩,则调用相对应的压缩方式。对于不支持的,则以管道方式转发给response。代码如下:

let raw = fs.createReadStream(realPath);
let acceptEncoding = request.headers['accept-encoding'] || '';

if (acceptEncoding.match(//bdeflate/b/)) {
  response.writeHead(200, { 'Content-Encoding': 'deflate' });
  raw.pipe(zlib.createDeflate()).pipe(response);
} else if (acceptEncoding.match(//bgzip/b/)) {
  response.writeHead(200, { 'Content-Encoding': 'gzip' });
  raw.pipe(zlib.createGzip()).pipe(response);
} else {
  response.writeHead(200, {});
  raw.pipe(response);
}

六. 安全性考虑

由于我们获取的静态资源文件夹路径,是通过客户端输入的path值与静态资源文件夹的路径进行合并而得。假如客户端输入的 ../1.html ,那岂不是可以访问静态资源文件夹以外的文件了?尽管浏览器可以干掉 .. 这两个点,但是如果采用curl方式访问的话,问题就会出现。

目前比较流行的做法是替换掉所有的 .. ,然后调用path.normailze()方法来处理掉不正常的 / 。对于不存在的文件访问,应该返回404。

realPath = path.join(staticPath, path.normalize(pathName.replace(//././g, "")));

七. 日志监控

// 正在完善中,敬请期待~ … …

八. 完整代码:

app.js:

let http = require("http"),
    url = require("url"),
    path = require("path"),
    fs = require("fs"),
    MIME = require("./MIME.js").type,
    zlib = require("zlib");

let staticPath = "./res/";

let Expires = {
    fileMatch: /^(gif|png|jpg|js|css)$/ig,
    maxAge: 60 * 60 * 24 * 365 // 一年
};

let app = http.createServer((request, response) => {
    let pathName = url.parse(request.url).pathname || "",
        realPath = path.join(staticPath, path.normalize(pathName.replace(//././g, "")));; // 请求文件的在磁盘中的真实地址

    fs.exists(realPath, (exists) => {
        if(!exists) {
            // 当文件不存在时
            response.writeHead(404, {"Content-Type": "text/plain"});

            response.write("This request URL ' " + realPath + " ' was not found on this server.");
            response.end();
        } else {
            // 当文件存在时
            fs.readFile(realPath, "binary", (err, file) => {
                if (err) {
                    // 文件读取出错
                    response.writeHead(500, {"Content-Type": "text/plain"});

                    response.end(err);
                } else {
                    // 当文件可被读取时,输出文本流
                    let extName = path.extname(realPath);
                    extName = extName ? extName.slice(1) : "";
                    let contentType = MIME[extName] || "text/plain";

                    if (extName.match(Expires.fileMatch)) {
                        let expires = new Date();
                        expires.setTime(expires.getTime() + Expires.maxAge * 1000);
                        response.setHeader("Expires", expires.toUTCString());
                        response.setHeader("Cache-Control", "max-age=" + Expires.maxAge);
                    }

                    let stat = fs.statSync(realPath);
                    let lastModified = stat.mtime.toUTCString();
                    response.setHeader("Last-Modified", lastModified);

                    if (request.headers["if-modified-since"] && lastModified == request.headers["if-modified-since"]) {
                        response.writeHead(304, "Not Modified");
                        response.end();
                        return;
                    }
                    let raw = fs.createReadStream(realPath);
                    let acceptEncoding = request.headers['accept-encoding'] || '';
                    if (acceptEncoding.match(//bdeflate/b/)) {
                      response.writeHead(200, { 'Content-Encoding': 'deflate' });
                      raw.pipe(zlib.createDeflate()).pipe(response);
                    } else if (acceptEncoding.match(//bgzip/b/)) {
                      response.writeHead(200, { 'Content-Encoding': 'gzip' });
                      raw.pipe(zlib.createGzip()).pipe(response);
                    } else {
                      response.writeHead(200, {});
                      raw.pipe(response);
                    }
                }
            });
        }
    });
});

app.listen(80, "127.0.0.1");

MIME.js:

let type = {
    "txt": "text/plain",
    "xml": "text/xml",
    "html": "text/html",
    "css": "text/css",
    "js": "text/javascript",
    "json": "application/json",
    "gif": "image/gif",
    "png": "image/png",
    "jpeg": "image/jpeg",
    "jpg": "image/jpeg",
    "svg": "image/svg+xml",
    "ico": "image/x-icon",
    "pdf": "application/pdf",
    "swf": "application/x-shockwave-flash",
    "tiff": "image/tiff",
    "wav": "audio/x-wav",
    "wma": "audio/x-ms-wma",
    "wmv": "video/x-ms-wmv"
};

exports.type = type;

九. 重构整理后的代码

显然,我们的代码已经有“意大利面条”的感觉了。对于层层回调,可以考虑使用Promise/A+来进行重构。

另外,我们的静态资源服务器可以封装成模块来使用,应考虑到static目录可有多个,Gzip压缩可配置等。

十. 参考

博文共赏:Node.js静态文件服务器实战: http://www.infoq.com/cn/news/2011/11/tyq-nodejs-static-file-server

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址