数据上传
单纯的头部报文无法携带大量的数据,在业务中,我们往往需要接收一些数据,比如表单提交、文件提交、JSON上传、XML上传等。
如果请求中还带有内容部分(如POST请求,它具有报头和内容),内容部分需要用户自行接收和解析,通过报头的Transfer-Encoding
或 Content-Length
即可判断请求中是否带有内容名。
var hasBody = function(req) {
return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};
表单数据
默认的表单提交,请求头中的Content-Type字段值为application/x-www-form-urlencoded
,由于它的报文体内容跟查询字符串相同foo=bar&baz=val
,因此可以直接使用queryString解析:
if (req.headers['content-type'] === 'application/x-www-form-urlencoded') {
req.body = querystring.parse(req.rawBody);
}
其他格式
常见的提交还有JSON和XML文件等,判断它们都是依据Content-Type字段,其中JSON类型的值为application/json
,XML的值为application/xml
。需要注意的是,在Content-Type中可能还附带如下所示的编码信息。
Content-Type: application/json; charset=utf-8
使用JSON.parse()解析JSON,使用第三方库解析XML
var xml2js = require('xml2js');
var handle = function (req, res) {
if (mime(req) === 'application/xml') {
xml2js.parseString(req.rawBody, function (err, xml) {
if (err) {
// 异常内容,响应Bad request
res.writeHead(400);
res.end('Invalid XML');
return;
}
req.body = xml;
todo(req, res);
});
} else if (mime(req) === 'application/json') {
try {
req.body = JSON.parse(req.rawBody);
} catch (e) {
// 异常内容,响应Bad request
res.writeHead(400);
res.end('Invalid JSON');
return;
}
}
};
附件上传
在前端HTML代码中,特殊表单与普通表单的差异在于该表单中可以含有file类型的控件,以及需要指定表单属性enctype为multipart/form-data
。
浏览器在遇到multipart/form-data
表单提交时,构造的请求报文与普通报文完全不同,首先它的报头中最为特殊的如下所示:
Content-Type: multipart/form-data; boundary=AaB03x
Content-Length: 18231
它代表本次提交的内容是多部分构成的,其中boundary=AaB03x指定的是每部分内容的分界符,AaB03x是随机生成的一段字符串,报文体的内容将通过在它前面添加--进行分割,报文结束时在它前后都加上--标识结束,另外Content-Length的值必须确保是报文体的长度。
数据上传与安全
内存限制
在解析表单、JSON和XML部分,我们采取的策略是先保存用户提交的所有数据,然后再解析处理,最后才传递给业务逻辑。这种策略存在的问题就是,仅仅适合数据量小的提交请求,如果攻击者每次提交1MB的内容,那么内存很快就会被占光。
解决这个问题有两个方案:
1.限制上传内容的大小,一旦超过限制,停止接受数据,并响应400状态码
2.通过流式解析,将数据导向到磁盘中,Node只保留文件路径等小数据。
// 限制上传数据量
var bytes = 1024;
function (req, res) {
var received = 0;
var len = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : null;
// 内容超过长度限制,返回请求实体过长的状态码
if (len && len > bytes) {
res.writeHead(413); res.end();
return;
}
// limit
req.on('data', function (chunk) {
// 内容长度累积超过限制。
received += chunk.length;
if (received > bytes) {
// 停止接收数据,触发end()
req.destroy();
}
});
handle(req, res);
};
CSRF: Cross-Site Request Forgery
通常而言,用户通过浏览器访问服务器端的Session ID是无法被第三方知道的,但是CSRF的攻击者并不需要知道Session ID就能让用户中招。
举例:用户C访问网站A,输入用户名密码成功通过验证,此时网站A返回Cookie信息给浏览器,用户可以成功发送请求到网站A,如果用户未退出网站A,被攻击者引诱访问了攻击者拥有的网站B,网站B有一段自执行代码去访问网站A,此时用户与网站A的cookie还未失效,那么就可以成功访问,并且这次请求的参数是由攻击者决定的,这样就能以你的名义操作你的账号。
解决方案:
- 验证 HTTP Referer 字段
如果黑客要对网站实施 CSRF 攻击,他只能在他自己的网站构造请求,当用户通过黑客的网站发送请求到后台时,该请求的 Referer 是指向黑客自己的网站, 然而,这种方法并非万无一失。Referer 的值是由浏览器提供的。事实上,对于某些浏览器,比如 IE6 或 FF2已经有一些方法可以篡改 Referer 值。即便是使用最新的浏览器,黑客无法篡改 Referer 值,这种方法仍然有问题。因为 Referer 值会记录下用户的访问来源,有些用户认为这样会侵犯到他们自己的隐私权,特别是有些组织担心 Referer 值会把组织内网中的某些信息泄露到外网中。
- 在请求地址中添加 token 并验证
为每一个请求的用户,在Session中赋予一个随机值,在做页面渲染的过程中,将这个token告知前端,使得前端才发送请求的时候会携带上token,后端收到请求判断token是否是自己给前端的,以此辨别该请求是否是伪造的。如果是动态生成的HTML或者是使用前端框架渲染,则需要前端程序员代码中去处理携带上token。
路由解析
MVC
MVC是一个分层模式,它的工作模式是:
1、路由解析,根据URL寻找到对应的控制器和行为(Controller)
2、行为调用相关的模型,进行数据操作(Model)
3、数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端。(View)
如何做到路由映射?
手工映射
var routes = [];
// Controller的集合
var use = function (path, action) {
routes.push([path, action]);
};
function (req, res) {
var pathname = url.parse(req.url).pathname;
for (var i = 0; i < routes.length; i++) {
var route = routes[i];
if (pathname === route[0]) {
// 根据URL寻找到对应的控制器和行为
var action = route[1];
action(req, res);
return;
}
}
// 处理404请求
handle404(req, res);
}
上面手工映射的问题有两个,
1、采用的是硬匹配,对于动态路径不方便处理。/profile/jacksontian
和/profile/hoover
两个路径是一样的模型,会出现重复的代码操作。
2、如果使用正则匹配还需要知道到底匹配到了什么,例如上面的username,到底是jacksontian还是hoover,将其匹配到的内容抽取出来设置到req.params
处。
自然映射
路由按照一种约定的方式自然而然地实现路由,例如:
/controller/action/param1/param2/param3
以/user/setting/12/1987
为例,它会按照约定去找controllers目录下的user文件,将其require出来,调用这个文件模块的setting方法,而其余的值作为参数传递给这个方法。
function (req, res) {
var pathname = url.parse(req.url).pathname;
var paths = pathname.split('/');
var controller = paths[1] || 'index';
var action = paths[2] || 'index';
var args = paths.slice(3);
var module;
try {
// require的缓存机制使得只有第一次是阻塞的
module = require('./controllers/' + controller);
} catch (ex) {
handle500(req, res);
return;
}
var method = module[action];
if (method) {
method.apply(null, [req, res].concat(args));
} else {
handle500(req, res);
}
}
RESTful
REST的全称是Representational State Transfer,中文含义为表现层状态转化,它的设计哲学主要是将服务端提供的内容实体看做一个资源,并表示在URL上,对这个资源的操作,主要体现在HTTP请求方法上,而不是原来的URL上。
对一个资源的增删查改,采用同一个URL,但是请求方式不同,后端根据不同的请求方式,做出不同的操作。
PUT /user/jacksontian
DELETE /user/jacksontian
GET /user/jacksontian
POST /user/jacksontian
过去设计资源的格式与后缀有很大的关联,在RESTful设计中,资源的具体格式由请求报头中的Accept
字段和服务器端的支持情况来决定,如果客户端同时接受JSON和XML格式的响应,那么它的Accept
字段是:Accept: application/json,application/xml
,服务器根据自己能够响应的格式做出响应,在响应中通过Content-Type
字段告知客户端是什么格式:Content-Type: application/json
,所以RESTful设计就是,通过URL设计资源、请求方法定义资源的操作、通过Accept决定资源的表现形式。
中间件
对于web应用而言,我们希望不用接触到这么多细节性的处理,为此我们引入了中间件(middleware)来简化和隔离这些基础设施和业务逻辑之间的细节,让开发者能够关注在业务的开发上,以达到提升开发效率的目的。
中间件用来帮我们完成基本功能,例如查询字符串的解析,cookie和session的处理,并把处理结果反映在req上传递下去,由于Node异步的原因,我们需要提供一种机制,在当前中间件处理完成后,通知下一个中间件执行。
var handle = function (req, res, stack) {
var next = function () {
// 从stack数组中取出中间件并执行
var middleware = stack.shift();
if (middleware) {
// 传入next()函数自身,使中间件能够执行结束后递归
middleware(req, res, next);
}
};
// 启动执行
next();
}
这里带来的疑问是,像querystring、cookie、session这样基础的功能中间件是否需要为每一个路由都进行设置呢?如果都设置将会演变成如下的路由配置:
app.get('/user/:username', querystring, cookie, session, getUser);
app.put('/user/:username', querystring, cookie, session, updateUser);
这并不是一个好的设计,我们需要将路由和中间件结合起来。
app.use(querystring);
app.use(cookie);
app.use(session);
app.get('/user/:username', getUser);
app.put('/user/:username', authorize, updateUser);
app.use = function (path) {
var handle;
// 如果第一参数是路径
if (typeof path === 'string') {
handle = {
path: pathRegexp(path),
stack: Array.prototype.slice.call(arguments, 1)
};
} else {
// 如果不是路径,则相当于是对所有路径进行中间件的处理
// 之后的所有请求都可以拿到处理后的结果,用与不用,取决于业务逻辑。
handle = {
path: pathRegexp('/'),
stack: Array.prototype.slice.call(arguments, 0)
};
}
routes.all.push(handle);
};
异常处理
如果某一个中间件出现错误该怎么办,我们需要为自己构建的web应用的稳定性和健壮性负责,应该为next()方法添加err参数,并捕获中间件直接抛出的同步异常。
var handle = function (req, res, stack) {
var next = function (err) {
if (err) {
return handle500(err, req, res, stack);
}
var middleware = stack.shift();
if (middleware) {
try {
middleware(req, res, next);
} catch (ex) {
next(err);
}
}
};
// 启动执行
next();
};
由于异步方法的异常不能直接捕获,因为异步调用都是直接返回,使用try/catch无法捕获到请求阶段的异常,所以需要在异步执行完的回调中将异常手动传递出来。
var session = function (req, res, next) {
var id = req.cookies.sessionid;
store.get(id, function (err, session) {
if (err) {
// 将异常通过next()传递
return next(err);
}
req.session = session;
next();
});
}
中间件与性能
使用中间件,我们可以发现业务逻辑往往是最后执行,为了让业务逻辑提早执行,尽早响应给终端用户,中间件的编写和使用是需要一番考究的,下面是两个主要提升的点:
编写高效的中间件
编写高效的中间件其实就是提升单个处理单元的处理速度,以尽早调用next()执行后续逻辑,因为中间件一旦匹配,那么每个请求都会使该中间件执行一次,哪怕只浪费1毫秒的执行时间,都会让我们的QPS显著下降(一秒内可以处理的请求数量称之为服务器的QPS)
常见的优化方法有:
1、使用已有API中高效的方法,而不是自己尝试编写。
2、缓存需要重复计算的结果
3、避免不必要的计算,比如HTTP报文体的解析,对于GET方法完全不需要。
合理使用路由,避免不必要的中间件的执行
合理的路由使得不必要的中间件不参与请求处理的过程,假设我们这里有一个静态文件的中间件,它会对请求进行判断,如果磁盘上存在对应文件,就响应对应的静态文件,否则就交给下游中间件处理。
var staticFile = function (req, res, next) {
var pathname = url.parse(req.url).pathname;
fs.readFile(path.join(ROOT, pathname), function (err, file) {
if (err) {
return next();
}
res.writeHead(200);
res.end(file);
});
};
如果我们以如下方式注册路由app.use(staticFile);
,那么意味着对根路径下所有的URL请求都会进行判断,对于这种情况,我们需要做的是提升匹配成功率app.use('/public', staticFile);
,这样只有/public
路径会匹配上,其他路径就不会涉及到该中间件。
页面渲染
内容响应
服务器端响应的报文,最终都要被终端处理,这个终端可能是命令行终端,也可能是代码终端,也可能是浏览器终端。服务器端的响应从一定程度上决定了客户端该如何处理响应的内容。
浏览器通过Content-Type的值来决定采用不同的渲染方式,这个值我们简称为MIME(Multipurpose Internet Mail Extensions),最早用于电子邮件,后来应用到浏览器中,不同的文件类型具有不同的MIME值,为了方便获知文件的MIME值,社区有专有的mime模块可以用来判断文件类型。
var mime = require('mime');
mime.lookup('/path/to/file.txt'); // => 'text/plain'
mime.lookup('file.txt'); // => 'text/plain'
mime.lookup('.TXT'); // => 'text/plain'
mime.lookup('htm'); // => 'text/html
// JSON application/json
// XML application/xml
// PDF application/pdf
// ...
除了MIME值外,Content-Type的值还可以包含一些参数,如字符集:
Content-Type: text/javascript; charset=utf-8
附件下载
在一些场景下,无论响应的内容是什么样的MIME值,需求中并不要求客户端去打开它,只需弹出并下载它即可,Content-Disposition
字段就是为了满足这种需求,客户端会根据它的值判断是应该将报文数据当做即时浏览的内容,还是可下载的附件,当内容值需即时查看时,它的值为inline
,当数据可以存为附件时,它的值为attachment
,另外Content-Disposition
还能通过参数指定保存时应该使用的文件名。
Content-Disposition: attachment; filename="filename.ext"
如果我们要设计一个响应附件下载的API,大致如下:
res.sendfile = function (filepath) {
fs.stat(filepath, function(err, stat) {
var stream = fs.createReadStream(filepath);
// 设置内容
res.setHeader('Content-Type', mime.lookup(filepath));
// 设置长度
res.setHeader('Content-Length', stat.size);
// 设置为附件
res.setHeader('Content-Disposition' 'attachment; filename="' + path.basename(filepath) + '"');
res.writeHead(200);
stream.pipe(res);
});
};
响应JSON
res.json = function (json) {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify(json));
};
响应跳转
res.redirect = function (url) {
res.setHeader('Location', url);
res.writeHead(302);
res.end('Redirect to ' + url);
};
视图渲染
模板是带有特殊标签的HTML片段,通过与数据的渲染,将数据填充到这些特殊标签中,最后生成普通的带数据的HTML片段,通常我们将渲染方法设计为render(),参数就是模板路径和数据。
res.render = function (view, data) {
res.setHeader('Content-Type', 'text/html');
res.writeHead(200);
// 实际渲染
var html = render(view, data);
res.end(html);
};
模板
为了使HTML与逻辑代码分离开来,催生了一些服务器端动态网页技术,如ASP、PHP、JSP。它们将动态语言部分通过特殊的标签(ASP和JSP以<% %>
作为标志,PHP则以 ?>
作为标志)包含起来,通过HTML和模板标签混排,将开发者从输出HTML的工作中解脱出来,这种方法虽然一定程度上减轻了开发维护的难度,但是页面还是充斥着大量的逻辑代码,这催生了MVC在动态网页技术中的发展,MVC将逻辑、显示、数据分离开来的方式,大大提高了项目的可维护性。
模板技术虽然多种多样,但它的实质就是将模板文件和数据通过模板引擎生成最终的HTML代码,形成模板技术的也就如下4个要素:模板语言、包含模板语言的模板文件、拥有动态数据的数据对象、模板引擎。模板技术使得网页中的动态内容和静态内容变得不互相依赖,数据开发者和模板开发者只要约定好数据结构,两者就不用互相影响了。
但模板技术并不是什么神秘的技术,它干的实际上是拼接字符传这样和底层的活,做的就是替换特殊标签的技术,只是各种模板有着各自的优缺点和技巧。
模板安全
模板技术用来替换特殊标签然后形成新的HTML代码,这样就很容易形成XSS攻击,例如将某个数据的值改为,如果渲染到页面上,这段脚本就会被执行,为了提高安全性,大多数模板提供了转义的功能,转义就是将能形成HTML标签的字符转换成安全的字符,这些字符主要有
&
、<
、>
、"
、'
var escape = function (html) {
return String(html)
.replace(/&(?!\w+;)/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
集成文件系统
var cache = {};
var VIEW_FOLDER = '/path/to/wwwroot/views';
res.render = function (viewname, data) {
if (!cache[viewname]) {
var text;
try {
text = fs.readFileSync(path.join(VIEW_FOLDER, viewname), 'utf8');
} catch (e) {
res.end('模板文件错误');
return;
}
cache[viewname] = complie(text);
}
var complied = cache[viewname];
res.writeHead(200, {'Content-Type': 'text/html'});
var html = complied(data);
res.end(html);
};
这个res.render()的实现中,虽然有同步读取文件的情况,但是由于采用了缓存,只会在第一次读取的时候造成整个进程的阻塞,一旦缓存生效,将不会反复读取文件,其次,缓存之前已经进行了编译,也不会每次读取都编译,并且与文件系统集成了,调用的时候只需要指定模板文件的名称和数据即可完成渲染。
app.get('/aaa', function (req, res) {
res.render('viewname', {});
});