sheila1227 / fe-blog Goto Github PK
View Code? Open in Web Editor NEW前端技术博客
前端技术博客
对于Node.js新手,搭建一个静态资源服务器是个不错的锻炼,从最简单的返回文件或错误开始,渐进增强,还可以逐步加深对http的理解。那就开始吧,让我们的双手沾满网络请求!
Note:
当然在项目中如果有使用express框架,用express.static一行代码就可以达到目的了:
app.use(express.static('public'))
这里我们要实现的正是
express.static
背后所做工作的一部分,建议同步阅读该模块源码。
不急着写下第一行代码,而是先梳理一下就基本功能而言有哪些步骤。
创建一个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()
方法会被调用。
现在给客户端返回文件时,我们并没有指定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
要增添对目录访问的支持,我们重新整理下响应的步骤:
/
作为要转到的location我们需要重写一下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
的文件,却存在同名目录,因此第一次会收到重定向响应,并发起一个对目录的新请求。
为了减少数据传输,减少请求数,继续添加缓存支持。首先梳理一下缓存的处理流程:
如果是第一次访问,请求报文首部不会包含相关字段,服务端在发送文件前做如下处理:
ETag
,设置ETag
头Last-Modified
,设置Last-Modified
头Expires
头Cache-Control
头(设置其max-age
值)浏览器收到响应后会存下这些标记,并在下次请求时带上与ETag
对应的请求首部If-None-Match
或与Last-Modified
对应的请求首部If-Modified-Since
。
如果是重复的请求:
Cache-Control
和Expires
确定)
如果未过期,直接使用缓存内容,也就是强缓存命中,并不会产生新的请求
如果已过期,会发起新的请求,并且请求会带上If-None-Match
或If-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应该大写),所以平常在浏览器中会看到大写或小写的首部字段。
但是node
的http
模块将首部字段都转成了小写,这样在代码中使用起来更方便些。所以访问header要用小写,如reqHeaders['if-none-match']
。不过,仍然可以用req.rawreq.rawHeaders
来访问原headers,它是一个[name1, value1, name2, value2, ...]
形式的数组。
现在来测试一下,因为设置的缓存有效时间是极小的5s,所以强缓存几乎不会命中,所以第二次访问文件会发出新的请求,因为服务端文件并没做什么改变,所以会返回304。
现在来修改一下请求的这张图片,比如修改一下size,目的是让服务端的再验证失败,因而必须给客户端发送200和最新的文件。
接下来把缓存有效时间改大一些,比如10分钟,那么在10分钟之内的重复请求,都会命中强缓存,浏览器不会向服务端发起新的请求(但network依然能观察到这条请求)。
服务器在发送很大的文档之前,对其进行压缩,可以节省传输用时。其过程是:
Accept-Encoding
头Accept-Encoding
请求头,并且支持该文件类型的压缩,压缩响应的实体主体(并不压缩头部),并附上Content-Encoding
首部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
头。
最后一步,使服务器支持范围请求,允许客户端只请求文档的一部分。其流程是:
Accept-Ranges
头(值表示表示范围的单位,通常是“bytes”),告诉客户端其接受范围请求Ranges
头,告诉服务端请求的是一个范围206 Partial Content
,发送指定范围内内容,并在Content-Range
头中指定该范围416 Requested Range Not Satisfiable
,并在Content-Range
中指明可接受范围请求中的Ranges
头格式为(这里不考虑多范围请求了):
Ranges: bytes=[start]-[end]
其中 start 和 end 并不是必须同时具有:
响应中的Content-Range
头有两种格式:
当范围有效返回 206 时:
Content-Range: bytes (start)-(end)/(total)
当范围无效返回 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:
请求一个无效范围返回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 命令会输出啥:
这样就可以在命令行传递端口、默认页等:
node app.js -p 8888 -i main.html
戳我的 GitHub repo: nodejs-static-webserver
在使用Node.js搭建静态资源服务器一文中我们完成了服务器对静态资源请求的处理,但并未涉及动态请求,目前还无法根据客户端发出的不同请求而返回个性化的内容。单靠静态资源岂能撑得起这些复杂的网站应用,本文将介绍如何使用Node
处理动态请求,以及如何搭建一个简易的 MVC 框架。因为前文已经详细介绍过静态资源请求如何响应,本文将略过所有静态部分。
先从一个简单示例入手,明白在 Node 中如何向客户端返回动态内容。
假设我们有这样的需求:
/actors
时返回男演员列表页/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。匹配成功时处理相应的逻辑。
上面的代码显然并不通用,而且在仅有两种路由匹配候选项(且还未区分请求方法),以及尚未使用数据库以及模板文件的前提下,代码都已经有些纠结了。因此接下来我们将搭建一个简易的MVC框架,使数据、模型、表现分离开来,各司其职。
MVC 分别指的是:
在 Node 中,MVC 架构下处理请求的过程如下:
以此为依据,我们需要准备以下模块:
创建如下目录:
-- server.js
-- lib
-- router.js
-- views
-- controllers
-- models
创建 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模块其实只需完成一件事,将请求导向正确的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 交给匹配上的中间件们处理。中间件是一个可访问请求对象和响应对象的函数,在中间件内可以做的事情包括:
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 添加了 use
、get
、post
等方法,每当调用这些方法时,给 routes 添加一条 route 规则。
Note:
Javascript 中函数是一种特殊的对象,能被调用的同时,还可以拥有属性、方法。
接下来的重点在 router
函数,它需要做的是:
req
对象中取得 method、pathname 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/erwe
、http://localhost:9527/actors
、http://localhost:9527/actresses
测试一下:
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:
继续,修改 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
试试:
router 模块就写到此,至于查询参数的格式化以及获取请求主体,比较琐碎就不试验了,需要可以直接使用 bordy-parser 等模块。
现在我们已经创建好了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 的任务:
在此 controller 中,我们将调用 model 模块的方法获取演员列表,接着将数据交给 view,交由 view 生成呈现出演员列表页的 html 字符串。最后将此字符串返回给客户端,在浏览器中呈现列表。
通常 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;
});
当 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;
};
在浏览器中测试一下:
至此,就大功告成啦!
戳我的 GitHub repo: node-mvc-framework
占坑
占坑
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.