《深入浅出Node.js》读书笔记7-1构建web应用

基础功能

本章内容将从http模块中服务器端的request事件开始分析。request事件发生于网络连接建立,客户端向服务器端发送报文,服务器端解析报文,发现HTTP请求端报头时。在已触发request事件前,它已准备好ServerRequest和ServerReponse对象以供对请求和响应报文对操作。

请求方法

HTTP_Parser在解析请求报文的时候,将请求行的请求方法设置为req.method。在RESTful类Web服务中请求方法十分重要。

  • PUT,新建一个资源
  • POST,更新一个资源
  • GET,查看一个资源
  • DELETE,删除一个资源

路径解析

HTTP_Parser在解析请求报文的时候,将请求行的第二部分设置为req.url
完整的URL地址是这样:

1
http://user:pass@host.com:8080/p/a/t/h?query=string#hash

我们可以根据路径查找磁盘中的文件,或者选择控制器。

查询字符串

使用url.parse()并传递第二个参数,可以将查询字符串解析为一个JSON对象。

1. 初识Cookie

Cookie是一个由浏览器和服务器共同协作实现的,Cookie的处理分为如下几步:

  • 服务器向客户端发送Cookie
  • 浏览器将Cookie保存
  • 之后每次浏览器都会将Cookie发向服务器端。

HTTP_parse会将cookie解析到req.headers.cookie。
Cookie值是key=value;key2=value2形式,如果我们需要Cookie,解析它也十分容易

1
2
3
4
5
6
7
8
9
10
11
12
var parseCookie = function(cookie){
var cookies = {};
if(!cookie) {
return cookies;
}
var list = cookie.split(';');
for(var i = 0; i < list.length;i++){
var pair = list[i].spilt('=');
cookies[pair[0].trim()] = pair[1];
}
return cookies;
}

响应报文中的Cookie值再Set-Cookie字段中,它的格式与请求中的格式不一样,主要选项如下:

1
Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

其中name=value是必须包含的部分,其余部分皆是可选参数。这些可选参数将会影响浏览器在后续将Cookie发送到服务器的行为:

  • path: 表示这个Cookie影响到的路径,当前访问的路径不满足该匹配时,浏览器则不发送这个Cookie;
  • Expires和Max-Age: 告知浏览器这个Cookie何时过期,如果不设置该选项,在关闭时会丢掉这个Cookie。Expires的值是一个UTC格式的时间字符串。Max-Age的值是数字,代表多少秒后过期。
    下面将Cookie序列化成符合规范的字符串
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var serialize = function (name, val, opt) {
    var pairs = [name + '=' + encode(val)];
    opt = opt || {};
    if (opt.maxAge) pairs.push('Max-Age=' + opt.maxAge);
    if (opt.domain) pairs.push('Domain=' + opt.domain);
    if (opt.path) pairs.push('Path=' + opt.path);
    if (opt.expires) pairs.push('Expires=' + opt.expires.toUTCString());
    if (opt.httpOnly) pairs.push('HttpOnly');
    if (opt.secure) pairs.push('Secure');
    return pairs.join('; ');
    };

2. Cookie的性能影响

一旦服务器向客户端发送了设置Cookie的意图,除非Cookie过期,否则客户端每次请求都会发送这些Cookie到服务器,一旦设置的Cookie过多,将会导致报头较大。

  • 减少Cookie的大小
  • 为静态组件使用不同的域名
  • 减少DNS查询

Session

Cookie最为严重的问题是可以在前后端进行修改,因此数据就极容易被篡改和伪造。所以,Cookie对于敏感数据的保护是无效的。
Session的数据只保留在服务端,客户端无法修改,数据也无须在协议中每次都传递。
虽然在服务端存储数据十分方便,但是如何将每个客户和服务器中的数据一一对应,有两种常见的实现方式:

  1. 基于Cookie来实现用户和数据的映射
    一旦服务器启用了Session,他将约定一个键值作为Session的口令。口令存放在Cookie中。一旦服务器检查到用户请求Cookie中没有携带该值或者过期,它就会为之生产一个值,这个值是唯一且不重复的值,并设定超时时间。

  2. 通过查询字符串来实现浏览器和服务器数据的对应
    它的原理是检查请求的查询字符串,如果没有值,会先生成新的带值的URL,然后形成跳转,让客户端重新发起请求。
    这种方案安全性更低。

1. Session与内存

如果将session数据直接存在变量session中,它位于内存中,当用户增多,我们很可能就会接触到内存限制的上限,并且内存中的数据量加大,必然会引起垃圾回收的频繁扫描,引起性能问题。
另一个问题,当利用多核CP而启动多个进程,用户请求的连接将可能随意分配到各个进程中,node的进程之间不能直接共享内存的,用户的session可能会引起错乱。
为了解决性能问题和session数据无法跨进程共享的问题,常用的方案是将session集中化,将原本可能分散在多个进程里的数据,统一转移到集中的数据存储中。目前常用的工具是Redis、Memcahed,这些高效的缓存。

2. Session与安全

Session的口令依然保存在客户端,当Web应用的用户十分多,自行设计的随机算法的一些口令值就有理论命中有效的口令值。一旦口令被伪造,服务器端的数据也可能间接被利用。这里提到的Session的安全,就主要指如何让这个口令更加安全。
有一种做法是将这个口令通过私钥加密进行签名,使得伪造的成本较高。

1
2
3
4
// 将值通过私钥签名,由.分割原值和签名
var sign = function(val, secret) {
return val + '.' + crypto.createHmac('sha256', secret).update(val).digest('hex')
}

在响应时,设置session值到Cookie中或者跳转URL中,接收请求时,检查签名。
在此基础上,我们还可以将客户端的某些独有信息与口令作为原值,然后签名。

XSS漏洞

XSS的全称是跨站脚本攻击(Cross Site Scripting),通常是由网站开发者决定哪些脚本可以执行在浏览器端,但是XSS漏洞会让别的脚本执行。它的主要形成原因多数是用户的输入没有被转义,而被直接执行。

缓存

大多数缓存只应用在GET请求中。使用缓存的流程如图:

简单来讲,本地没有文件时,浏览器必然会请求服务器端的内容,并将这部分内容放置在本地的某个缓存目录中。在第二次请求时,它将对本地文件进行检查,如果不能确定这份本地文件是否可以直接使用,它将会发起一次条件请求。所谓条件请求,就是在普通对GET请求报文中,附带If-Modified-Since字段,如下:

1
If-Modified-Since: Sun, 03 Feb 2013 06:01:12 GMT

它将询问服务器是否有更新的版本,本地文件的最后修改时间。如果服务器没有更新的版本,只需响应一个304状态码,客户端就使用本地版本。如果服务器端有新的版本,就将新的内容发送给客户端,客户端放弃本地版本。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var handle = function (req, res) { 
fs.stat(filename, function (err, stat) {
var lastModified = stat.mtime.toUTCString();
if (lastModified === req.headers['if-modified-since']) {
res.writeHead(304, "Not Modified");
res.end();
} else {
fs.readFile(filename, function(err, file) {
var lastModified = stat.mtime.toUTCString(); res.setHeader("Last-Modified", lastModified); res.writeHead(200, "Ok");
res.end(file);
});
}
});
};

这里的条件请求采用时间戳的方式实现,但是时间戳有一些缺陷存在:

  • 文件的时间戳改动但内容并不一定改动;
  • 时间戳只能精确到秒级别,更新频繁的内容将无法生效;

为此HTTP1.1中引入ETag来解决这个问题ETag的全称是Enity Tag,由服务器端生成,服务器端可以决定它的生成规则。如果根据文件内容生成散列值,那么条件请求将不会受到时间戳改动造成的带宽浪费。
ETag的请求和响应是If-None-Match/ETag,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var handle = function(req, res) {
var hash = function(file) {
var shasum = crypto.createHash('sha1');
return shasum.update(str).digest('base64');
}
var noneMatch = req.headers['if-none-match'];
if(hash === noneMatch) {
res.writeHead(304, 'Not Modified');
res.end();
} else {
res.setHeader('ETag', hash);
res.writeHead('200', 'ok');
res.end(file);
}
}

尽管条件请求可以在文件内容没有修改的情况下节省带宽,但是它依然会发起一个HTTP请求,使得客户端依然会一定时间来等待响应。那么如何让浏览器不发起条件请求而直接使用本地版本?服务器在响应内容时设置ExpiresCache-Control头,浏览器将根据该值进行缓存。这两个值有何区别?
HTTP1.0时,在服务器设置Expires可以告知浏览器要缓存内容,代码如下:

1
2
3
4
5
6
7
8
9
var handle = function(req, res) {
fs.readFile(filename, function(err, file){
var expires = new Date();
expires.setTime(expires.getTime() + 10*365*24*60*60*1000);
res.setHeader('Expires', expires.toUTCString());
res.writeHead(200, 'ok');
res.end(file);
})
}

Expires是一个GMT格式的时间字符串。浏览器在接到这个过期值后,只要本地还存在这个缓存文件,在到期之前它都不会再发起请求。但是Expires的缺陷在于浏览器与服务器之间的时间可能不一致。这种情况下,Cache-Control以更丰富的形式,实现相同的功能。

1
2
3
4
5
6
7
var handle = function(req, res) {
fs.readfile(filename, function(err, file){
res.setHeader('Cache-Control', 'max-age='+10*365*24*60*60*1000);
res.writeHead(200, 'ok');
res.end(file);
})
}

上面代码为Cache-Control设置了max-age值,它比Expires优秀的地方在于,Cache-Control能够避免浏览器与服务器端时间不同步带来的不一致性问题,只要进行类似倒计时的方式计算过期时间即可。除此之外,Cache-Control还能设置public、private、no-cache、no-store等更精细地控制缓存的选项。
在浏览器中如果两个值同时存在,且被同时支持,max-age会覆盖Expires。

Basic认证

Basic认证是当客户端与服务器端进行请求时,允许通过用户名和密码实现的一种身份认证方式。
如果一个页面需要Basic认证,它会检查请求报文头中的Authorization字段的内容,该字段的值由认证方式和加密值构成。
一般而言,未认证的情况下,浏览器会弹出对话框进行交互式提交认证信息。

当认证通过后,服务器端响应200状态码之后浏览器会保存用户名和密码口令,在后续的请求中都携带上Authorization信息。

数据上传

Node的http模块只对HTTP报文的头部进行了解析,然后触发request事件。如果请求中还带有内容部分,内容部分需要用户自行接收和解析。通过报头的Transfer-EncodingContent-Length即可判断请求中是否带有内容。
在HTTP_Parser解析报头结束后,报文内容部分通过data事件触发,我们只需以流的方式处理即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 判断请求中是否带有内容
var hasBody = function(req) {
return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
}

function(req, res) {
if(hasBody(req)) {
var buffers = [];
req.on('data', function(chunk){
buffers.push(chunk);
})
req.on('end', function(){
req.rawBody = Buffers.concat(buffers).toUTCString();
handle(req, res);
})
} else {
handle(req, res);
}
}

表单数据

默认的表单提交,请求头如下:

1
2
3
4
5
6
7
8
9
10
Content-Type: application/x-www-form-urlencoded
```
它的报文内容和查询字符串相同,因此解析它十分容易:
```javascript
var handle = function(req, res) {
if(req.headers['contnet-type'] === 'application/x-www-form-urlencoded'){
req.body = querystring.parse(req.rawBody);
}
todo(req, res);
}

其他格式

除了表单数据,常见的提交还有JSON和XML文件等,判断和解析他们的原理都比较相似,都是依据Content-Type中的值决定。

附件上传

在一些业务场景中,往往需要用户直接提交文件。在前端HTML代码中,特殊表单与普通表单的差异在于该表单中可以含有file类型的控件,以及需要指定表单属性enctypemultipart/form-data
浏览器在遇到multipart/form-data表单提交时,构造的请求报文与普通表单不一样:

1
Content-Type: multipart/form-data;boundry=AaB03x

它代表本次提交的内容是由多部分构成的,其中boundry=AaB03x指定的是每部分内容的边界符,AaB03x是随机生成的一段字符串,报文体的内容将通过在它前面添加–进行分割,报文结束时在它前后加上–表示结束。另外,Content-Length的值必须确保是报文体的长度。

数据上传与安全

1. 内存限制

在解析表单、JSON、XML部分,我们采取的策略是先保存用户提交的所以数据,然后再解析处理,最后才传递给业务逻辑。这种策略存在潜在的问题是,它仅仅适合数据量小的提交请求,一旦数据量过大,将发生内存被占光的情况。
要解决这个问题主要有两个方案:

  • 限制上传内容的大小,一旦超过限制,停止接收数据,并响应400状态码;
  • 通过流式解析,将数据导向到磁盘中,Node只保留文件路径等小数据

2. CSRF

CSRF全称是Cross-Site Request Forgery, 跨站请求伪造。
解决方案是,给每个请求的用户,在session中赋予一个随机值。

路由解析

对于不同的业务,我们期望有不同的处理方式,这带来了路由的选择问题。

文件路径型

1. 静态文件

URL的路径与网站目录的路径一致。

2. 动态文件

在MVC模式流行起来之前,根据文件路径执行动态脚本也是基本的路由方式。原理是:Web服务器根据URL路径找到对应的文件,然后根据后缀去寻找脚本的解析器,并传入HTTP请求的上下文。解析器执行脚本,并输出响应报文,达到完成服务的目的。

MVC

MVC模型的主要思想是将业务逻辑按职责分离:

  • 控制器(Controller),一组行为的集会
  • 模型(Model),数据相关的操作和封装
  • 视图(View),视图的渲染
    这是目前最经典的分层模式,它的工作模式如下:
  • 路由解析,根据URL寻找到对应的控制器和行为,
  • 行为调用相关的模型,进行数据操作,
  • 数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端

如何根据URL做路由映射?一种是手工关联映射,一种是自然关联映射。手工关联映射有一个对应的路由文件来将URL映射到对应的控制器。

1. 手工映射

手工映射除了需要手工配置路由较为原始外,它对URL的要求十分灵活。通过正则匹配、参数解析来获取URL中的值。

2. 自然映射

按一种约定的方式自然而然地实现路由,而无须去维护路由映射。

RESTful

RESTful全称是Representational State Transfer,中文含义是表现层状态转化。符合REST规范的设计,我们称之为RESTful设计。它的设计哲学主要将服务器端提供的内容实体看作一个资源,并表现在URL上。
通过URL设计资源,请求方法定义资源的操作,通过Accept决定资源的表现形式。

请求方法

中间件

Node异步的原因,在当前中间件处理完成后,通知下一个中间件执行,通过尾触发的方式实现,一个基本的中间件如下:

1
2
3
4
var middleware = function(req, res, next) {
// TODO
next()
}
  • 路由映射

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    var routes = {'all': []};
    var app = {};

    ['get', 'put', 'delete', 'post'].forEach(function (method) {
    routes[method] = [];
    app[method] = function (path, action) {
    routes[method].push([pathRegexp(path), action]);
    };
    });

    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);
    };
  • 匹配部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    var match = function(pathname, routes) {
    var stacks = [];
    for(var i =0;i < routes.length; i++) {
    var route = routes[i];
    // 正则匹配
    var reg = route.path.regexp;
    var matched = reg.exec(pathname);
    if(matched) {
    // 抽取具体指
    // 代码省略
    // 将中间件都保存起来
    stacks = stacks.concat(route.stack)
    }
    }
    return stacks;
    }
  • 处理中间件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    var handle = function(req, res, stack){
    var next = function(){
    // 从stack数组中取出中间件并执行
    var middleware = stack.shift();
    if(middlwware){
    // 传入next()函数本身,使中间件能够执行结束后递归
    middleware(req, res, next);
    }
    }

    // 启动执行
    next();
    }
  • 分发

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function(req, res) {
    var pathname = url.parse(req, url).pathname;
    var method = req.method.toLowerCase();
    // 获取all()方法里的中间件
    var stacks = match(pathname, routes.all);
    if(routes.hasOwnPerperty) {
    // 根据请求方法分发,获取相关中间件
    stacks.concat(match(pathname, routes[method]));
    }

    if(stacks.length) {
    handle(req, res, stacks)
    } else {
    // 处理404
    handle404(req, res)
    }
    }

异常处理

next()方法需要添加err参数,并捕获中间件直接抛出的同步异常,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var handle = function(req, res, stack) {
var next = function(err) {
if(err) {
return handle500(err, req, res, stack)
}
var middleware = stack.shift();
if(middlewware){
try {
middleware(req, res, next);
} catch(ex) {
next(err);
}
}
}

next();
}

由于异步方法的异常不能直接捕获,中间件异步产生的异常需要自己传递出来,如下

1
2
3
4
5
6
7
8
9
10
var session = function(req, res, next) {
var id = req.cookie.sessionid;
store.get(id, function(err, session){
if(err) {
return next(err);
}
req.session = session;
next();
});
};

另外,需要异常处理中间件,它的参数有4个,如下

1
2
3
4
var middleware = function(err, req, res, next) {
// TODO
next();
}

handle500()会对中间件按参数进行选取,然后递归执行。

1
2
3
4
5
6
7
8
9
10
11
12
var handle500 = function(err, req, res, stack){
stack = satck.filter(middleware => middleware.length === 4);

var next = function(){
var middleware = stack.shift();
if(middleware) {
middleware(err, req, res, next);
}
};

next();
}

中间件与性能

尽管添加了强大的中间件组织能力,但是业务逻辑往往是在最后才执行。为了让业务逻辑提早执行,注重看下面两点:

  • 编写高效的中间件
  • 合理利用路由,避免不必要的中间件执行

1. 编写高效的中间件

常见的优化方法:

  • 使用高效的方法
  • 缓存需要重复计算的结果(需要控制缓存用量)
  • 避免不必要的计算。

2. 合理使用路由

合理的路由使得不必要的中间件不参与请求处理的过程。

页面渲染

承接上文谈论的HTTP响应实现的技术细节,主要包含内容响应和页面渲染两个部分。

内容响应

服务端的响应从一定程度上决定或指示了客户端如何处理响应的内容。
内容响应的过程中,响应报头中的Content-*字段十分重要。

1
2
3
Content-Encoding: gzip
Content-Length: 21170
Content-Type: text/javascript;charset=utf-8

客户端在接收搭配这个报文后,正确的处理过程是通过gzip来解码报文体中的内容,用长度校验报文体内容是否正确,然后再以字符集UTF-8将解码后的脚本插入到文档节点中。

1.MIME

如果想要客户端用正确的方法来处理响应的内容,了解MIME必不可少。下面两段代码在客户端有什么样的差异:

1
2
res.writeHead(200, {'Content-Type': 'text/plain'}); 
res.end('<html><body>Hello World</body></html>\n');

或者

1
2
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('<html><body>Hello World</body></html>\n');

在网页中,前者显示<html><body>Hello World</body></html>,后者显示Hello World
浏览器正是通过不同的Content-Type的值来决定采用不同的渲染方式,这个值简称为MIME(Multipurpose Internet Mail Extensions)值。

2.附件下载

在一些场景下,并不要求客户端去打开响应内容,只需弹出并下载。
Content-Disposition:客户端会根据它的值判断是应该将报文数据当作即时浏览的内容,还是可下载的附件。当内容只需即时查看时,它的值为inline,当数据可以存为附件时,它的值为attachment。另外,Content-Disposition字段能通过参数指定保存时应该使用的文件名。示例如下:

1
Content-Disposition: attachment;filename='filename.ext'

如果要设计一个响应附件下载的API,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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.writeHeader(200);
stream.pipe(res)
})
}

3.响应JSON

1
2
3
4
5
res.json = function(json){
res.setHeader('Location', url);
res.writeHead(302);
res.end('Redirect to ' + url);
}

4.响应跳转

当我们当URL因为某些问题(譬如权限限制)不能处理当前请求,需要将用户跳转到别的URL时,我们也能封装出一个快捷的方法实现跳转:

1
2
3
4
5
res.redirct = function(url){
res.setHeader('Location', url);
res.writeHead(302);
res.end('Redirect to' + url);
}

视图渲染

暂无

模版

暂无

Bigpipe

暂无

liborn wechat
欢迎您扫一扫上面的微信二维码,订阅我的公众号!