Nodejs入门 - 搭建一个简单的静态资源站点(二)

momo314相同方式共享非商业用途署名转载

在上一篇博客中,我们已经得到了一个非常非常简陋的静态资源文件站点,简陋到什么程度呢,他的逻辑代码甚至全部放在 start.js 中,看着就不爽,那么,把他分离出来怎么样?

第一步 - 模块化

好哒,首先我们来分析一下一个正常的静态资源文件站点项目都应该包括那些模块:

  1. 服务器模块:Node.js在实现一个web项目的同时会自己实现一个服务器
  2. 路由模块:分析请求URL,匹配URL模板并调用对应的controller方法
  3. 控制器模块:执行相应的业务逻辑并返回对应的结果内容(静态资源文件)
  4. 配置模块:在上一篇博客中已经基本实现,视情况添加需要的配置项即可
  5. 缓存模块:降低IO和带宽的好帮手
  6. CORS跨域模块:字体文件 *.ttf 在跨域获取的时候会被浏览器安全策略组织,我们需要为其接触跨域限制

额。。。差不多够了吧。

1. 服务器模块

那么,动手吧!先从你开始,服务器模块!不多说,上代码,先上为敬:

// server/server.js

var http = require('http');
var url = require('url');
var fs = require('fs');
var path = require('path');
var configurations = require('./configurations/configurations').configurations;

var start = function(){
    http.createServer(function (request, response) {

        var pathname = url.parse(request.url).pathname;
        var realPath = configurations.assetspath + pathname.replace(/\//, '\\');

        fs.exists(realPath, function (exists) {
            if (!exists) {
                response.writeHead(404, {'Content-Type': 'text/plain'});
                response.write('URL NOT FOUND: ' + pathname);
                response.end();
            } else {
                fs.readFile(realPath, "binary", function(err, file) {
                    if (err) {
                        response.writeHead(500, {'Content-Type': 'text/plain'});
                        response.end(err);
                    } else {
                        var ext = path.extname(realPath); 
                        ext = ext ? ext.slice(1) : 'unknown';
                        var contentType = configurations.mime[ext] || 'text/plain';

                        response.writeHead(200, {'Content-Type': contentType});
                        response.write(file, 'binary');
                        response.end();
                    }
                });
            }
        });

    }).listen(configurations.port);
};

exports.start = start;
// start.js

var server = require('./server/server');
server.start();

嗯,还挺顺利的,那么,下面就该轮到路由模块了吧

2. 路由模块 和 控制器模块

嗯,路由模块的功能呢,其实就是指定一大堆URL模板,然后根据请求的URL来分析应该调用哪个页面或者方法,并对其进行调用。那么,我们来设想一下,我觉得路由模块应该是这样的:

一个路由模块router,其中有一个方法叫做route,传入一些参数,路由到对应的方法,执行一些逻辑,得到一些结果:

参数: 因为我们需要根据 request.url 来分析具体应该调用那个方法,所以就至少需要在参数中传入 request.url 或者 url.parse(request.url).pathname,然后我们发现这俩东西其实没什么区别,都是从request中获取的,那么request中还有什么有用的东西呢,是不是需要直接传入整个request作为参数呢?嗯...我建议还是直接传request吧!( ̄▽ ̄) 大家都是搞开发的,我也不骗你,在cntroller-action中试用request对象简直不要太常见好不好,最起码的基于cookie的身份验证啊喂!

结果: 具体业务逻辑执行完了总该返回点什么吧,嗯,更别说Nodejs还是自己实现httpserver,什么statuscode,header,content统统都要自己写啊,所以request对象是跑不了了。那么怎么获取request呢,HttpContex.Current.Response?别逗了,咱还是老老实实把response传进来吧。

路由: 那么,到底怎么路由到一个具体页面呢?还记得吗,在上一篇文章中我们是在 pathname 之前拼上 /assets/ 目录,并直接返回这个文件的。所以,显然我们其实只有一个handler,就是返回静态资源文件的控制器:assets,而且其实并不需要什么路由。 但是,我们还是假装我们有很多handler,并装模作样的实现一个路由:首先我们有一个handlers,它是一个键值对集合,为了保证他能跟我们的handler一一对应并能顺利的调用,我们将可以把URL模板作为键,将可执行的handler方法作为值。 类似于:

// handlers/handlers.js
exports.handlers = {
    /*这里我们假设assets模块也放在handlers目录下,并且假装我们已经实现了它*/
    "assets": require('./assets').assets
    //"home/index" : require('./index').index
};

这样,我们就可以通过 handlers['assets']() 来调用我们的assets方法了。

于是,一个崭新的路由模板就诞生了:

// router/router.js
var url = require('url');
var handlers = require('../handlers/handlers').handlers;

function route(request, response) {
    //注意:这里其实不应该完全信任pathname,需要做一定的安全校验和路径兼容(就不贴出来了)
    var pathname = url.parse(request.url).pathname;
    if (typeof handlers[pathname] === 'function') {
        /*
         * 其实这里应该是要有路由模板的匹配判断的,并不能直接根据pathname来判断,
         * 毕竟URL上参数是可变的,大家可以自己试一下如何实现
         * */
        handlers[pathname](request, response, pathname);
    } else {
        //调用assets-handler
        handlers['assets'](request, response, pathname);
    }
}

exports.route = route;

这样,我们在server中的代码也就可以精简为:

// require other moudules...
var router = require('../router/router');

var server = http.createServer(function(request, response) {
    router.route(request, response);
}).listen(configurations.port);

读取文件什么的也都可以扔到assets-handler中实现了,这样显得我们的server模块更加精简了呢!

4. 具体业务逻辑实现 - 客户端缓存支持

资源文件呢我们现在已经可以正常返回了,但是如果每次请求一个文件都需要从硬盘上读取文件到内存中,然后再返回给客户端的话,不但磁盘IO太高,对带宽也有不小的压力呢。所以,缓存毋庸置疑是我们的核心功能之一。

那么,对浏览器端缓存还不是很了解的同学可以看下下面这篇文章,熟悉的童鞋请直接略过...

浏览器缓存机制简介

浏览器缓存涉及到的HTPP Headers其实还挺多的,不过就我们的项目来说呢,ETag没什么必要,所以呢,我们就用 Expires Cache-Control Last-Modified 好了,应该足够了。

Expires 好说,就是返回一个 当前时间 + 缓存时间 的时间戳而已; Cache-Control 也简单,显然这里只能用 max-age Last-Modified 就有些麻烦,因为需要获取到指定文件的最后修改时间,怎么办呢?给大家介绍一个 fs模块的新成员:fs.stat(path, [callback(err, stats)]),获取文件相关信息,其中 stat.mtime就是最后修改时间啦

最后贴代码:

// handlers/assets.js
var configurations = require('../configurations/configurations').configurations;
var url = require('url');
var fs = require('fs');
var path = require('path');

var assets = function(request, response, pathname){

    var realPath = path.join(configurations.assetspath, pathname.replace(/\//g, '\\'));

    fs.exists(realPath, function(exists) {
        if (!exists) {
            response.writeHead(404, {'Content-Type': 'text/plain'});
            response.write('URL NOT FOUND: ' + pathname);
            response.end();
        } else {
            //Content-Type
            var ext = path.extname(realPath); 
            ext = ext ? ext.slice(1) : 'unknown';
            var contentType = configurations.mime[ext] || 'text/plain';
            response.setHeader('Content-Type', contentType + ';charset=utf8');

            fs.stat(realPath, function (err, stat) {
                //Last-Modified
                var lastModified = stat.mtime.toUTCString();
                response.setHeader('Last-Modified', lastModified);
                //Expires && Cache-Control
                var expires = new Date();
                expires.setTime(expires.getTime() + configurations.browsercacheseconds * 1000);
                response.setHeader('Expires', expires.toUTCString());
                response.setHeader('Cache-Control', 'max-age=' + configurations.browsercacheseconds);

                var ifModifiedSinceTime = request.headers['If-Modified-Since'.toLowerCase()] ? new Date(request.headers['If-Modified-Since'.toLowerCase()]) : new Date(1900, 0, 1);
                var lastModifiedTime = new Date(lastModified);

                if (ifModifiedSinceTime >= lastModifiedTime) {
                    response.writeHead(304, 'Not Modified');
                    response.end();
                } else {
                    // 输出资源文件
                    response.writeHead(200, 'Ok');
                    response.write(file, 'binary');
                    response.end();
                }
            });
        }
    });
};

exports.assets = assets;

6. 具体业务逻辑实现 - gzip压缩

gzip压缩的好处咱就不多说了,Nodejs其实是原生支持zgip压缩的,试用方法特别简单:

var zlib = require('zlib');

var raw = fs.createReadStream(realPath);
var acceptEncoding = request.headers['accept-encoding'] || '';
var matched = ext.match(configurations.compress.filenameextension);

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);
}

然后,我们就可以用这一段来替换上述 assets.js 中的 输出资源文件 一段了

7. 具体业务逻辑实现 - CORS跨域支持

CORS跨域资源共享 也不多解释了,还不了解的亲们可以看下这篇文章

CORS 跨域资源共享

总之呢,我们在发现请求目标是一个字体文件 *.ttf 的时候呢,就需要启用CORS跨域资源共享的流程了,步骤呢,也很简单:

  1. 首先判断 Access-Control-Request-Method Access-Control-Request-HeadersOrigin 3个请求头是否与我们要求的匹配。如果验证通过呢,我们就返回200 OK; 否则就返回400 Bad Reuqest以阻止跨域。
  2. 然后判断请求谓词是否为 OPTIONS 。如果是的话呢,我们认为这是个预检请求,直接终止这个请求,让他返回给客户端;否则呢,我们将会继续执行后续流程,将所需的字体文件返回给客户端。
  3. 没了...

嗯。。。这篇已经贴了好多代码了,CORS的代码就不贴了,想要看源码的直接下载好了 ╮(╯▽╰)╭

Demo源码下载

✎﹏ 本文来自于 momo314和他们家的猫,文章原创,转载请注明作者并保留原文链接。