Giter Club home page Giter Club logo

fe-blog's People

Contributors

sheila1227 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

fe-blog's Issues

使用Node.js搭建静态资源服务器

对于Node.js新手,搭建一个静态资源服务器是个不错的锻炼,从最简单的返回文件或错误开始,渐进增强,还可以逐步加深对http的理解。那就开始吧,让我们的双手沾满网络请求!

Note:

当然在项目中如果有使用express框架,用express.static一行代码就可以达到目的了:

app.use(express.static('public'))

这里我们要实现的正是express.static背后所做工作的一部分,建议同步阅读该模块源码。

基本功能

不急着写下第一行代码,而是先梳理一下就基本功能而言有哪些步骤。

  1. 在本地根据指定端口启动一个http server,等待着来自客户端的请求
  2. 当请求抵达时,根据请求的url,以设置的静态文件目录为base,映射得到文件位置
  3. 检查文件是否存在
  4. 如果文件不存在,返回404状态码,发送not found页面到客户端
  5. 如果文件存在:
    • 打开文件待读取
    • 设置response header
    • 发送文件到客户端
  6. 等待来自客户端的下一个请求

实现基本功能

代码结构

创建一个nodejs-static-webserver目录,在目录内运行npm init初始化一个package.json文件。

mkdir nodejs-static-webserver && cd "$_"
// initialize package.json
npm init

接着创建如下文件目录:

-- config
---- default.json
-- static-server.js
-- app.js

default.json

{
    "port": 9527,
    "root": "/Users/sheila1227/Public",
    "indexPage": "index.html"
}

default.js存放一些默认配置,比如端口号、静态文件目录(root)、默认页(indexPage)等。当这样的一个请求http://localhost:9527/myfiles/抵达时. 如果根据root映射后得到的目录内有index.html,根据我们的默认配置,就会给客户端发回index.html的内容。

static-server.js

const http = require('http');
const path = require('path');
const config = require('./config/default');

class StaticServer {
    constructor() {
        this.port = config.port;
        this.root = config.root;
        this.indexPage = config.indexPage;
    }

    start() {
        http.createServer((req, res) => {
            const pathName = path.join(this.root, path.normalize(req.url));
            res.writeHead(200);
            res.end(`Requeste path: ${pathName}`);
        }).listen(this.port, err => {
            if (err) {
                console.error(err);
                console.info('Failed to start server');
            } else {
                console.info(`Server started on port ${this.port}`);
            }
        });
    }
}

module.exports = StaticServer;

在这个模块文件内,我们声明了一个StaticServer 类,并给其定义了start方法,在该方法体内,创建了一个server对象,监听rquest事件,并将服务器绑定到配置文件指定的端口。在这个阶段,我们对于任何请求都暂时不作区分地简单地返回请求的文件路径。path模块用来规范化连接和解析路径,这样我们就不用特意来处理操作系统间的差异。

app.js

const StaticServer = require('./static-server');

(new StaticServer()).start();

在这个文件内,调用上面的static-server模块,并创建一个StaticServer实例,调用其start方法,启动了一个静态资源服务器。这个文件后面将不需要做其他修改,所有对静态资源服务器的完善都发生在static-server.js内。

在目录下启动程序会看到成功启动的log:

> node app.js

Server started on port 9527

在浏览器中访问,可以看到服务器将请求路径直接返回了。

路由处理

之前我们对任何请求都只是向客户端返回文件位置而已,现在我们将其替换成返回真正的文件:

    routeHandler(pathName, req, res) {
        
    }

    start() {
        http.createServer((req, res) => {
            const pathName = path.join(this.root, path.normalize(req.url));
            this.routeHandler(pathName, req, res);
        }).listen(this.port, err => {
			...
        });
    }

将由routeHandler来处理文件发送。

读取静态文件

读取文件之前,用fs.stat检测文件是否存在,如果文件不存在,回调函数会接收到错误,发送404响应。

    respondNotFound(req, res) {
        res.writeHead(404, {
            'Content-Type': 'text/html'
        });
        res.end(`<h1>Not Found</h1><p>The requested URL ${req.url} was not found on this server.</p>`);
    }

    respondFile(pathName, req, res) {
        const readStream = fs.createReadStream(pathName);
        readStream.pipe(res);
    }

    routeHandler(pathName, req, res) {
        fs.stat(pathName, (err, stat) => {
            if (!err) {
                this.respondFile(pathName, req, res);
            } else {
                this.respondNotFound(req, res);
            }
        });
    }

Note:

读取文件,这里用的是流的形式createReadStream而不是readFile,是因为后者会在得到完整文件内容之前将其先读到内存里。这样万一文件很大,再遇上多个请求同时访问,readFile就承受不来了。使用文件可读流,服务端不用等到数据完全加载到内存再发回给客户端,而是一边读一边发送分块响应。这时响应里会包含如下响应头:

Transfer-Encoding:chunked

默认情况下,可读流结束时,可写流的end()方法会被调用。

MIME支持

现在给客户端返回文件时,我们并没有指定Content-Type头,虽然你可能发现访问文本或图片浏览器都可以正确显示出文字或图片,但这并不符合规范。任何包含实体主体(entity body)的响应都应在头部指明文件类型,否则浏览器无从得知类型时,就会自行猜测(从文件内容以及url中寻找可能的扩展名)。响应如指定了错误的类型也会导致内容的错乱显示,如明明返回的是一张jpeg图片,却错误指定了header:'Content-Type': 'text/html',会收到一堆乱码。

虽然有现成的mime模块可用,这里还是自己来实现吧,试图对这个过程有更清晰的理解。

在根目录下创建mime.js文件:

const path = require('path');

const mimeTypes = {
    "css": "text/css",
    "gif": "image/gif",
    "html": "text/html",
    "ico": "image/x-icon",
    "jpeg": "image/jpeg",
	 ...
};

const lookup = (pathName) => {
    let ext = path.extname(pathName);
    ext = ext.split('.').pop();
    return mimeTypes[ext] || mimeTypes['txt'];
}

module.exports = {
    lookup
};

该模块暴露出一个lookup方法,可以根据路径名返回正确的类型,类型以‘type/subtype’表示。对于未知的类型,按普通文本处理。

接着在static-server.js中引入上面的mime模块,给返回文件的响应都加上正确的头部字段:

    respondFile(pathName, req, res) {
        const readStream = fs.createReadStream(pathName);
        res.setHeader('Content-Type', mime.lookup(pathName));
        readStream.pipe(res);
    }

重新运行程序,会看到图片可以在浏览器中正常显示了。

Note:

需要注意的是,Content-Type说明的应是原始实体主体的文件类型。即使实体经过内容编码(如gzip,后面会提到),该字段说明的仍应是编码前的实体主体的类型。

添加其他功能

至此,已经完成了基本功能中列出的几个步骤,但依然有很多需要改进的地方,比如如果用户输入的url对应的是磁盘上的一个目录怎么办?还有,现在对于同一个文件(从未更改过)的多次请求,服务端都是勤勤恳恳地一遍遍地发送回同样的文件,这些冗余的数据传输,既消耗了带宽,也给服务器添加了负担。另外,服务器如果在发送内容之前能对其进行压缩,也有助于减少传输时间。

读取文件目录

现阶段,用url: localhost:9527/testfolder去访问一个指定root文件夹下真实存在的testfolder的文件夹,服务端会报错:

Error: EISDIR: illegal operation on a directory, read

要增添对目录访问的支持,我们重新整理下响应的步骤:

  1. 请求抵达时,首先判断url是否有尾部斜杠
  2. 如果有尾部斜杠,认为用户请求的是目录
    • 如果目录存在
      • 如果目录下存在默认页(如index.html),发送默认页
      • 如果不存在默认页,发送目录下内容列表
    • 如果目录不存在,返回404
  3. 如果没有尾部斜杠,认为用户请求的是文件
    • 如果文件存在,发送文件
    • 如果文件不存在,判断同名的目录是否存在
      • 如果存在该目录,返回301,并在原url上添加上/作为要转到的location
      • 如果不存在该目录,返回404

我们需要重写一下routeHandler内的逻辑:

    routeHandler(pathName, req, res) {
        fs.stat(pathName, (err, stat) => {
            if (!err) {
                const requestedPath = url.parse(req.url).pathname;
                if (hasTrailingSlash(requestedPath) && stat.isDirectory()) {
                    this.respondDirectory(pathName, req, res);
                } else if (stat.isDirectory()) {
                    this.respondRedirect(req, res);
                } else {
                    this.respondFile(pathName, req, res);
                }
            } else {
                this.respondNotFound(req, res);
            }
        });
    }

继续补充respondRedirect方法:

    respondRedirect(req, res) {
        const location = req.url + '/';
        res.writeHead(301, {
            'Location': location,
            'Content-Type': 'text/html'
        });
        res.end(`Redirecting to <a href='${location}'>${location}</a>`);
    }

浏览器收到301响应时,会根据头部指定的location字段值,向服务器发出一个新的请求。

继续补充respondDirectory方法:

    respondDirectory(pathName, req, res) {
        const indexPagePath = path.join(pathName, this.indexPage);
        if (fs.existsSync(indexPagePath)) {
            this.respondFile(indexPagePath, req, res);
        } else {
            fs.readdir(pathName, (err, files) => {
                if (err) {
                    res.writeHead(500);
                    return res.end(err);
                }
                const requestPath = url.parse(req.url).pathname;
                let content = `<h1>Index of ${requestPath}</h1>`;
                files.forEach(file => {
                    let itemLink = path.join(requestPath,file);
                    const stat = fs.statSync(path.join(pathName, file));
                    if (stat && stat.isDirectory()) {
                        itemLink = path.join(itemLink, '/');
                    }                 
                    content += `<p><a href='${itemLink}'>${file}</a></p>`;
                });
                res.writeHead(200, {
                    'Content-Type': 'text/html'
                });
                res.end(content);
            });
        }
    }

当需要返回目录列表时,遍历所有内容,并为每项创建一个link,作为返回文档的一部分。需要注意的是,对于子目录的href,额外添加一个尾部斜杠,这样可以避免访问子目录时的又一次重定向。

在浏览器中测试一下,输入localhost:9527/testfolder,指定的root目录下并没有名为testfolder的文件,却存在同名目录,因此第一次会收到重定向响应,并发起一个对目录的新请求。

缓存支持

为了减少数据传输,减少请求数,继续添加缓存支持。首先梳理一下缓存的处理流程:

  1. 如果是第一次访问,请求报文首部不会包含相关字段,服务端在发送文件前做如下处理:

    • 如服务器支持ETag,设置ETag
    • 如服务器支持Last-Modified,设置Last-Modified
    • 设置Expires
    • 设置Cache-Control头(设置其max-age值)

    浏览器收到响应后会存下这些标记,并在下次请求时带上与ETag对应的请求首部If-None-Match或与Last-Modified对应的请求首部If-Modified-Since

  2. 如果是重复的请求:

    • 浏览器判断缓存是否过期(通过Cache-ControlExpires确定)
      • 如果未过期,直接使用缓存内容,也就是强缓存命中,并不会产生新的请求

      • 如果已过期,会发起新的请求,并且请求会带上If-None-MatchIf-Modified-Since,或者兼具两者

      • 服务器收到请求,进行缓存的新鲜度再验证:

        • 首先检查请求是否有If-None-Match首部,没有则继续下一步,有则将其值与文档的最新ETag匹配,失败则认为缓存不新鲜,成功则继续下一步
        • 接着检查请求是否有If-Modified-Since首部,没有则保留上一步验证结果,有则将其值与文档最新修改时间比较验证,失败则认为缓存不新鲜,成功则认为缓存新鲜

        当两个首部皆不存在或者验证结果是不新鲜时,发送200及最新文件,并在首部更新新鲜度。

        当验证结果是缓存仍然新鲜时(也就是弱缓存命中),不需发送文件,仅发送304,并在首部更新新鲜度

为了能启用或关闭某种验证机制,我们在配置文件里增添如下配置项:

default.json

{
	...
    "cacheControl": true,
    "expires": true,
    "etag": true,
    "lastModified": true,
    "maxAge": 5
}

这里为了能测试到缓存过期,将过期时间设成了非常小的5秒。

StaticServer类中接收这些配置:

class StaticServer {
    constructor() {
		 ...
        this.enableCacheControl = config.cacheControl;
        this.enableExpires = config.expires;
        this.enableETag = config.etag;
        this.enableLastModified = config.lastModified;
        this.maxAge = config.maxAge;
    }

现在,我们要在原来的respondFile前横加一杠,增加是要返回304还是200的逻辑。

    respond(pathName, req, res) {
        fs.stat(pathName, (err, stat) => {
            if (err) return respondError(err, res);
            this.setFreshHeaders(stat, res);
            if (this.isFresh(req.headers, res._headers)) {
                this.responseNotModified(res);
            } else {
                this.responseFile(pathName, res);
            }
        });

    }

准备返回文件前,根据配置,添加缓存相关的响应首部。

    generateETag(stat) {
        const mtime = stat.mtime.getTime().toString(16);
        const size = stat.size.toString(16);
        return `W/"${size}-${mtime}"`;
    }

    setFreshHeaders(stat, res) {
        const lastModified = stat.mtime.toUTCString();
        if (this.enableExpires) {
            const expireTime = (new Date(Date.now() + this.maxAge * 1000)).toUTCString();
            res.setHeader('Expires', expireTime);
        }
        if (this.enableCacheControl) {
            res.setHeader('Cache-Control', `public, max-age=${this.maxAge}`);
        }
        if (this.enableLastModified) {
            res.setHeader('Last-Modified', lastModified);
        }
        if (this.enableETag) {
            res.setHeader('ETag', this.generateETag(stat));
        }
    }

需要注意的是,上面使用了ETag弱验证器,并不能保证缓存文件与服务器上的文件是完全一样的。关于强验证器如何实现,可以参考etag包的源码。

下面是如何判断缓存是否仍然新鲜:

    isFresh(reqHeaders, resHeaders) {
        const  noneMatch = reqHeaders['if-none-match'];
        const  lastModified = reqHeaders['if-modified-since'];
        if (!(noneMatch || lastModified)) return false;
        if(noneMatch && (noneMatch !== resHeaders['etag'])) return false;
        if(lastModified && lastModified !== resHeaders['last-modified']) return false;
        return true;
    }

需要注意的是,http首部字段名是不区分大小写的(但http method应该大写),所以平常在浏览器中会看到大写或小写的首部字段。

但是nodehttp模块将首部字段都转成了小写,这样在代码中使用起来更方便些。所以访问header要用小写,如reqHeaders['if-none-match']。不过,仍然可以用req.rawreq.rawHeaders来访问原headers,它是一个[name1, value1, name2, value2, ...]形式的数组。

现在来测试一下,因为设置的缓存有效时间是极小的5s,所以强缓存几乎不会命中,所以第二次访问文件会发出新的请求,因为服务端文件并没做什么改变,所以会返回304。

304

现在来修改一下请求的这张图片,比如修改一下size,目的是让服务端的再验证失败,因而必须给客户端发送200和最新的文件。

200

接下来把缓存有效时间改大一些,比如10分钟,那么在10分钟之内的重复请求,都会命中强缓存,浏览器不会向服务端发起新的请求(但network依然能观察到这条请求)。

cache

内容编码

服务器在发送很大的文档之前,对其进行压缩,可以节省传输用时。其过程是:

  1. 浏览器在访问网站时,默认会携带Accept-Encoding
  2. 服务器在收到请求后,如果发现存在Accept-Encoding请求头,并且支持该文件类型的压缩,压缩响应的实体主体(并不压缩头部),并附上Content-Encoding首部
  3. 浏览器收到响应,如果发现有Content-Encoding首部,按其值指定的格式解压报文

对于图片这类已经经过高度压缩的文件,无需再额外压缩。因此,我们需要配置一个字段,指明需要针对哪些类型的文件进行压缩。

default.json

{
	...
    "zipMatch": "^\\.(css|js|html)$"
}

static-server.js

	constructor() {
		...
        this.zipMatch = new RegExp(config.zipMatch);
    }

zlib模块来实现流压缩:

    compressHandler(readStream, req, res) {
       const acceptEncoding = req.headers['accept-encoding'];
       if (!acceptEncoding || !acceptEncoding.match(/\b(gzip|deflate)\b/)) {
           return readStream;
       } else if (acceptEncoding.match(/\bgzip\b/)) {
           res.setHeader('Content-Encoding', 'gzip');
           return readStream.pipe(zlib.createGzip());
       } else if (acceptEncoding.match(/\bdeflate\b/)) {
           res.setHeader('Content-Encoding', 'deflate');
           return readStream.pipe(zlib.createDeflate());
       }
   }

因为配置了图片不需压缩,在浏览器中测试会发现图片请求的响应中没有Content-Encoding头。

范围请求

最后一步,使服务器支持范围请求,允许客户端只请求文档的一部分。其流程是:

  1. 客户端向服务端发起请求
  2. 服务端响应,附上Accept-Ranges头(值表示表示范围的单位,通常是“bytes”),告诉客户端其接受范围请求
  3. 客户端发送新的请求,附上Ranges头,告诉服务端请求的是一个范围
  4. 服务端收到范围请求,分情况响应:
    • 范围有效,服务端返回206 Partial Content,发送指定范围内内容,并在Content-Range头中指定该范围
    • 范围无效,服务端返回416 Requested Range Not Satisfiable,并在Content-Range中指明可接受范围

请求中的Ranges头格式为(这里不考虑多范围请求了):

Ranges: bytes=[start]-[end]

其中 start 和 end 并不是必须同时具有:

  1. 如果 end 省略,服务器应返回从 start 位置开始之后的所有字节
  2. 如果 start 省略,end 值指的就是服务器该返回最后多少个字节
  3. 如果均未省略,则服务器返回 start 和 end 之间的字节

响应中的Content-Range头有两种格式:

  1. 当范围有效返回 206 时:

    Content-Range: bytes (start)-(end)/(total)
    
  2. 当范围无效返回 416 时:

    Content-Range: bytes */(total)
    

添加函数处理范围请求:

    rangeHandler(pathName, rangeText, totalSize, res) {
        const range = this.getRange(rangeText, totalSize);
        if (range.start > totalSize || range.end > totalSize || range.start > range.end) {
            res.statusCode = 416;
            res.setHeader('Content-Range', `bytes */${totalSize}`);
            res.end();
            return null;
        } else {
            res.statusCode = 206;
            res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${totalSize}`);
            return fs.createReadStream(pathName, { start: range.start, end: range.end });
        }
    }

Postman来测试一下。在指定的root文件夹下创建一个测试文件:

testfile.js

This is a test sentence.

请求返回前六个字节 ”This “ 返回 206:

206

请求一个无效范围返回416:

416

读取命令行参数

至此,已经完成了静态服务器的基本功能。但是每一次需要修改配置,都必须修改default.json文件,非常不方便,如果能接受命令行参数就好了,可以借助 yargs 模块来完成。

var options = require( "yargs" )
    .option( "p", { alias: "port",  describe: "Port number", type: "number" } )
    .option( "r", { alias: "root", describe: "Static resource directory", type: "string" } )
    .option( "i", { alias: "index", describe: "Default page", type: "string" } )
    .option( "c", { alias: "cachecontrol", default: true, describe: "Use Cache-Control", type: "boolean" } )
    .option( "e", { alias: "expires", default: true, describe: "Use Expires", type: "boolean" } )
    .option( "t", { alias: "etag", default: true, describe: "Use ETag", type: "boolean" } )
    .option( "l", { alias: "lastmodified", default: true, describe: "Use Last-Modified", type: "boolean" } )
    .option( "m", { alias: "maxage", describe: "Time a file should be cached for", type: "number" } )
    .help()
    .alias( "?", "help" )
    .argv;

瞅瞅 help 命令会输出啥:

help command

这样就可以在命令行传递端口、默认页等:

node app.js -p 8888 -i main.html

参考

  1. 使用Node.js搭建简易Http服务器
  2. 博文共赏:Node.js静态文件服务器实战
  3. HTTP 206 Partial Content In Node.js

源码

戳我的 GitHub repo: nodejs-static-webserver

使用Node.js实现简易MVC框架

使用Node.js搭建静态资源服务器一文中我们完成了服务器对静态资源请求的处理,但并未涉及动态请求,目前还无法根据客户端发出的不同请求而返回个性化的内容。单靠静态资源岂能撑得起这些复杂的网站应用,本文将介绍如何使用Node处理动态请求,以及如何搭建一个简易的 MVC 框架。因为前文已经详细介绍过静态资源请求如何响应,本文将略过所有静态部分。

一个简单的示例

先从一个简单示例入手,明白在 Node 中如何向客户端返回动态内容。

假设我们有这样的需求:

  1. 当用户访问 /actors 时返回男演员列表页
  2. 当用户访问 /actresses 时返回女演员列表

可以用以下的代码完成功能:

const http = require('http');
const url = require('url');

http.createServer((req, res) => {
    const pathName = url.parse(req.url).pathname;
    if (['/actors', '/actresses'].includes(pathName)) {
        res.writeHead(200, {
            'Content-Type': 'text/html'
        });
        const actors = ['Leonardo DiCaprio', 'Brad Pitt', 'Johnny Depp'];
        const actresses = ['Jennifer Aniston', 'Scarlett Johansson', 'Kate Winslet'];
        let lists = [];
        if (pathName === '/actors') {
            lists = actors;
        } else {
            lists = actresses;
        }

        const content = lists.reduce((template, item, index) => {
            return template + `<p>No.${index+1} ${item}</p>`;
        }, `<h1>${pathName.slice(1)}</h1>`);
        res.end(content);
    } else {
        res.writeHead(404);
        res.end('<h1>Requested page not found.</h1>')
    }
}).listen(9527);

上面代码的核心是路由匹配,当请求抵达时,检查是否有对应其路径的逻辑处理,当请求匹配不上任何路由时,返回 404。匹配成功时处理相应的逻辑。

simple request

上面的代码显然并不通用,而且在仅有两种路由匹配候选项(且还未区分请求方法),以及尚未使用数据库以及模板文件的前提下,代码都已经有些纠结了。因此接下来我们将搭建一个简易的MVC框架,使数据、模型、表现分离开来,各司其职。

搭建简易MVC框架

MVC 分别指的是:

  1. M: Model (数据)
  2. V: View (表现)
  3. C: Controller (逻辑)

在 Node 中,MVC 架构下处理请求的过程如下:

  1. 请求抵达服务端
  2. 服务端将请求交由路由处理
  3. 路由通过路径匹配,将请求导向对应的 controller
  4. controller 收到请求,向 model 索要数据
  5. model 给 controller 返回其所需数据
  6. controller 可能需要对收到的数据做一些再加工
  7. controller 将处理好的数据交给 view
  8. view 根据数据和模板生成响应内容
  9. 服务端将此内容返回客户端

以此为依据,我们需要准备以下模块:

  1. server: 监听和响应请求
  2. router: 将请求交由正确的controller处理
  3. controllers: 执行业务逻辑,从 model 中取出数据,传递给 view
  4. model: 提供数据
  5. view: 提供 html

代码结构

创建如下目录:

-- server.js
-- lib
	-- router.js
-- views
-- controllers
-- models

server

创建 server.js 文件:

const http = require('http');
const router = require('./lib/router')();

router.get('/actors', (req, res) => {
    res.end('Leonardo DiCaprio, Brad Pitt, Johnny Depp');
});

http.createServer(router).listen(9527, err => {
    if (err) {
        console.error(err);
        console.info('Failed to start server');
    } else {
        console.info(`Server started`);
    }
});

先不管这个文件里的细节,router是下面将要完成的模块,这里先引入,请求抵达后即交由它处理。

router 模块

router模块其实只需完成一件事,将请求导向正确的controller处理,理想中它可以这样使用:

const router = require('./lib/router')();
const actorsController = require('./controllers/actors');

router.use((req, res, next) => {
	console.info('New request arrived');
	next()
});

router.get('/actors', (req, res) => {
    actorsController.fetchList();
});

router.post('/actors/:name', (req, res) => {
    actorsController.createNewActor();
});

总的来说,我们希望它同时支持路由中间件和非中间件,请求抵达后会由 router 交给匹配上的中间件们处理。中间件是一个可访问请求对象和响应对象的函数,在中间件内可以做的事情包括:

  1. 执行任何代码,比如添加日志和处理错误等
  2. 修改请求 (req) 和响应对象 (res),比如从 req.url 获取查询参数并赋值到 req.query
  3. 结束响应
  4. 调用下一个中间件 (next)

Note:

需要注意的是,如果在某个中间件内既没有终结响应,也没有调用 next 方法将控制权交给下一个中间件, 则请求就会挂起

__非路由中间件__通过以下方式添加,匹配所有请求:

router.use(fn);

比如上面的例子:

router.use((req, res, next) => {
	console.info('New request arrived');
	next()
});

__路由中间件__通过以下方式添加,以 请求方法和路径精确匹配:

router.HTTP_METHOD(path, fn)

梳理好了之后先写出框架:

/lib/router.js

const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'];

module.exports = () => {
    const routes = [];

    const router = (req, res) => {
        
    };

    router.use = (fn) => {
        routes.push({
            method: null,
            path: null,
            handler: fn
        });
    };

    METHODS.forEach(item => {
        const method = item.toLowerCase();
        router[method] = (path, fn) => {
            routes.push({
                method,
                path,
                handler: fn
            });
        };
    });
};

以上主要是给 router 添加了 usegetpost 等方法,每当调用这些方法时,给 routes 添加一条 route 规则。

Note:

Javascript 中函数是一种特殊的对象,能被调用的同时,还可以拥有属性、方法。

接下来的重点在 router 函数,它需要做的是:

  1. req对象中取得 method、pathname
  2. 依据 method、pathname 将请求与routes数组内各个 route 按它们被添加的顺序依次匹配
  3. 如果与某个route匹配成功,执行 route.handler,执行完后与下一个 route 匹配或结束流程 (后面详述)
  4. 如果匹配不成功,继续与下一个 route 匹配,重复3、4步骤
    const router = (req, res) => {
        const pathname = decodeURI(url.parse(req.url).pathname);
        const method = req.method.toLowerCase();
        let i = 0;

        const next = () => {
            route = routes[i++];
            if (!route) return;
            const routeForAllRequest = !route.method && !route.path;
            if (routeForAllRequest || (route.method === method && pathname === route.path)) {
                route.handler(req, res, next);
            } else {
                next();
            }
        }

        next();
    };

对于非路由中间件,直接调用其 handler。对于路由中间件,只有请求方法和路径都匹配成功时,才调用其 handler。当没有匹配上的 route 时,直接与下一个route继续匹配。

需要注意的是,在某条 route 匹配成功的情况下,执行完其 handler 之后,还会不会再接着与下个 route 匹配,就要看开发者在其 handler 内有没有主动调用 next() 交出控制权了。

在__server.js__中添加一些route:

router.use((req, res, next) => {
	console.info('New request arrived');
	next()
});

router.get('/actors', (req, res) => {
    res.end('Leonardo DiCaprio, Brad Pitt, Johnny Depp');
});

router.get('/actresses', (req, res) => {
    res.end('Jennifer Aniston, Scarlett Johansson, Kate Winslet');
});

router.use((req, res, next) => {
    res.statusCode = 404;
    res.end();
});

每个请求抵达时,首先打印出一条 log,接着匹配其他route。当匹配上 actors 或 actresses 的 get 请求时,直接发回演员名字,并不需要继续匹配其他 route。如果都没匹配上,返回 404。

在浏览器中依次访问 http://localhost:9527/erwehttp://localhost:9527/actorshttp://localhost:9527/actresses 测试一下:

404

network 中观察到的结果符合预期,同时后台命令行中也打印出了三条 New request arrived语句。

接下来继续改进 router 模块。

首先添加一个 router.all 方法,调用它即意味着为所有请求方法都添加了一条 route:

    router.all = (path, fn) => {
        METHODS.forEach(item => {
            const method = item.toLowerCase();
            router[method](path, fn);
        })
    };

接着,添加错误处理。

/lib/router.js

const defaultErrorHander = (err, req, res) => {
    res.statusCode = 500;
    res.end();
};

module.exports = (errorHander) => {
    const routes = [];

    const router = (req, res) => {
			...
        errorHander = errorHander || defaultErrorHander;

        const next = (err) => {
            if (err) return errorHander(err, req, res);
            ...
        }

        next();
    };

server.js

...
const router = require('./lib/router')((err, req, res) => {
    console.error(err);
    res.statusCode = 500;
    res.end(err.stack);
});
...

默认情况下,遇到错误时会返回 500,但开发者使用 router 模块时可以传入自己的错误处理函数将其替代。

修改一下代码,测试是否能正确执行错误处理:

router.use((req, res, next) => {
	console.info('New request arrived');
	next(new Error('an error'));
});

这样任何请求都应该返回 500:

error stack

继续,修改 route.path 与 pathname 的匹配规则。现在我们认为只有当两字符串相等时才让匹配通过,这没有考虑到 url 中包含路径参数的情况,比如:

localhost:9527/actors/Leonardo

router.get('/actors/:name', someRouteHandler);

这条route应该匹配成功才是。

新增一个函数用来将字符串类型的 route.path 转换成正则对象,并存入 route.pattern:

const getRoutePattern = pathname => {
  pathname = '^' + pathname.replace(/(\:\w+)/g, '\(\[a-zA-Z0-9-\]\+\\s\)') + '$';
  return new RegExp(pathname);
};

这样就可以匹配上带有路径参数的url了,并将这些路径参数存入 req.params 对象:

        const matchedResults = pathname.match(route.pattern);
        if (route.method === method && matchedResults) {
            addParamsToRequest(req, route.path, matchedResults);
            route.handler(req, res, next);
        } else {
            next();
        }
const addParamsToRequest = (req, routePath, matchedResults) => {
    req.params = {};
    let urlParameterNames = routePath.match(/:(\w+)/g);
    if (urlParameterNames) {
        for (let i=0; i < urlParameterNames.length; i++) {
            req.params[urlParameterNames[i].slice(1)] = matchedResults[i + 1];
        }
    }
}

添加个 route 测试一下:

router.get('/actors/:year/:country', (req, res) => {
    res.end(`year: ${req.params.year} country: ${req.params.country}`);
});

访问http://localhost:9527/actors/1990/China试试:

url parameters

router 模块就写到此,至于查询参数的格式化以及获取请求主体,比较琐碎就不试验了,需要可以直接使用 bordy-parser 等模块。

controller

现在我们已经创建好了router模块,接下来将 route handler 内的业务逻辑都转移到 controller 中去。

修改__server.js__,引入 controller:

...
const actorsController = require('./controllers/actors');
...
router.get('/actors', (req, res) => {
    actorsController.getList(req, res);
});

router.get('/actors/:name', (req, res) => {
    actorsController.getActorByName(req, res);
});

router.get('/actors/:year/:country', (req, res) => {
    actorsController.getActorsByYearAndCountry(req, res);
});
...

新建__controllers/actors.js__:

const actorsTemplate = require('../views/actors-list');
const actorsModel = require('../models/actors');

exports.getList = (req, res) => {
    const data = actorsModel.getList();
    const htmlStr = actorsTemplate.build(data);
    res.writeHead(200, {
        'Content-Type': 'text/html'
    });
    res.end(htmlStr);
};

exports.getActorByName = (req, res) => {
    const data = actorsModel.getActorByName(req.params.name);
    const htmlStr = actorsTemplate.build(data);
    res.writeHead(200, {
        'Content-Type': 'text/html'
    });
    res.end(htmlStr);
};

exports.getActorsByYearAndCountry = (req, res) => {
    const data = actorsModel.getActorsByYearAndCountry(req.params.year, req.params.country);
    const htmlStr = actorsTemplate.build(data);
    res.writeHead(200, {
        'Content-Type': 'text/html'
    });
    res.end(htmlStr);
};

在 controller 中同时引入了 view 和 model, 其充当了这二者间的粘合剂。回顾下 controller 的任务:

  1. controller 收到请求,向 model 索要数据
  2. model 给 controller 返回其所需数据
  3. controller 可能需要对收到的数据做一些再加工
  4. controller 将处理好的数据交给 view

在此 controller 中,我们将调用 model 模块的方法获取演员列表,接着将数据交给 view,交由 view 生成呈现出演员列表页的 html 字符串。最后将此字符串返回给客户端,在浏览器中呈现列表。

从 model 中获取数据

通常 model 是需要跟数据库交互来获取数据的,这里我们就简化一下,将数据存放在一个 json 文件中。

/models/test-data.json

[
    {
        "name": "Leonardo DiCaprio",
        "birth year": 1974,
        "country": "US",
        "movies": ["Titanic", "The Revenant", "Inception"]
    },
    {
        "name": "Brad Pitt",
        "birth year": 1963,
        "country": "US",
        "movies": ["Fight Club", "Inglourious Basterd", "Mr. & Mrs. Smith"]
    },
    {
        "name": "Johnny Depp",
        "birth year": 1963,
        "country": "US",
        "movies": ["Edward Scissorhands", "Black Mass", "The Lone Ranger"]
    }
]

接着就可以在 model 中定义一些方法来访问这些数据。

models/actors.js

const actors = require('./test-data');

exports.getList = () => actors;

exports.getActorByName = (name) => actors.filter(actor => {
    return actor.name == name;
});

exports.getActorsByYearAndCountry = (year, country) => actors.filter(actor => {
    return actor["birth year"] == year && actor.country == country;
});

view

当 controller 从 model 中取得想要的数据后,下一步就轮到 view 发光发热了。view 层通常都会用到模板引擎,如 dust 等。同样为了简化,这里采用简单替换模板中占位符的方式获取 html,渲染得非常有限,粗略理解过程即可。

创建 /views/actors-list.js:

const actorTemplate = `
<h1>{name}</h1>
<p><em>Born: </em>{contry}, {year}</p>
<ul>{movies}</ul>
`;

exports.build = list => {
    let content = '';
    list.forEach(actor => {
        content += actorTemplate.replace('{name}', actor.name)
                    .replace('{contry}', actor.country)
                    .replace('{year}', actor["birth year"])
                    .replace('{movies}', actor.movies.reduce((moviesHTML, movieName) => {
                        return moviesHTML + `<li>${movieName}</li>`
                    }, ''));
    });
    return content;
};

在浏览器中测试一下:

test mvc

至此,就大功告成啦!

参考

  1. Nodejs实现一个简单的服务器
  2. Creating an MVC framework for our Node.js page - getting ready for scalability

源码

戳我的 GitHub repo: node-mvc-framework

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.