用NodeJS打造你的静态文件服务器(下)
但是,貌似我们有提到gzip这样的东西。对于CSS,JS等文件如果不采用gzip的话,还是会浪费掉部分网络带宽。那么接下来把gzip搞起吧。
GZip启用
如果你是前端达人,你应该是知道YUI Compressor或Google Closure Complier这样的压缩工具的。在这基础上,再进行gzip压缩,则会减少很多的网络流量。那么,我们看看Node中,怎么把gzip搞起类。
要用到gzip,就需要zlib模块,该模块在Node的0.5.8版本开始原生支持。
1
|
var zlib
= require( "zlib" ); |
对于图片一类的文件,不需要进行gzip压缩,所以我们在config.js中配置一个启用压缩的列表。
1
2
3
|
exports.Compress
= { match:
/css|js|html/ig }; |
这里为了防止大文件,也为了满足zlib模块的调用模式,将读取文件改为流的形式进行读取。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
var raw
= fs.createReadStream(realPath); var acceptEncoding
= request.headers[ 'accept-encoding' ]
|| "" ; var matched
= ext.match(config.Compress.match); if (matched
&& acceptEncoding.match(/\bgzip\b/)) { response.writeHead(200, "Ok" ,
{ 'Content-Encoding' : 'gzip' }); raw.pipe(zlib.createGzip()).pipe(response); } else if (matched
&& acceptEncoding.match(/\bdeflate\b/)) { response.writeHead(200, "Ok" ,
{ 'Content-Encoding' : 'deflate' }); raw.pipe(zlib.createDeflate()).pipe(response); } else { response.writeHead(200, "Ok" ); raw.pipe(response); } |
对于支持压缩的文件格式以及浏览器端接受gzip或deflate压缩,我们调用压缩。若不,则管道方式转发给response。
启用压缩其实就这么简单。如果你有fiddler的话,可以监听一下请求,会看到被压缩的请求。
最终app.js文件的代码如下:
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
var server
= http.createServer( function (request,
response) { var pathname
= url.parse(request.url).pathname; var realPath
= path.join( "assets" ,
pathname); path.exists(realPath, function (exists)
{ if (!exists)
{ response.writeHead(404, "Not
Found" ,
{ 'Content-Type' : 'text/plain' }); response.write( "This
request URL " +
pathname + "
was not found on this server." ); response.end(); } else { var ext
= path.extname(realPath); ext
= ext ? ext.slice(1) : 'unknown' ; var contentType
= mime[ext] || "text/plain" ; response.setHeader( "Content-Type" ,
contentType); fs.stat(realPath, function (err,
stat) { var lastModified
= stat.mtime.toUTCString(); var ifModifiedSince
= "If-Modified-Since" .toLowerCase(); response.setHeader( "Last-Modified" ,
lastModified); if (ext.match(config.Expires.fileMatch))
{ var expires
= new Date(); expires.setTime(expires.getTime()
+ config.Expires.maxAge * 1000); response.setHeader( "Expires" ,
expires.toUTCString()); response.setHeader( "Cache-Control" , "max-age=" +
config.Expires.maxAge); } if (request.headers[ifModifiedSince]
&& lastModified == request.headers[ifModifiedSince]) { response.writeHead(304, "Not
Modified" ); response.end(); } else { var raw
= fs.createReadStream(realPath); var acceptEncoding
= request.headers[ 'accept-encoding' ]
|| "" ; var matched
= ext.match(config.Compress.match); if (matched
&& acceptEncoding.match(/\bgzip\b/)) { response.writeHead(200, "Ok" ,
{ 'Content-Encoding' : 'gzip' }); raw.pipe(zlib.createGzip()).pipe(response); } else if (matched
&& acceptEncoding.match(/\bdeflate\b/)) { response.writeHead(200, "Ok" ,
{ 'Content-Encoding' : 'deflate' }); raw.pipe(zlib.createDeflate()).pipe(response); } else { response.writeHead(200, "Ok" ); raw.pipe(response); } } }); } }); }); |
1
|
|
安全问题
我们搞了一大堆的事情,但是安全方面也不能少。想想哪一个地方是最容易出问题的? 我们发现上面的这段代码写得还是有点纠结的,通常这样纠结的代码我是不愿意拿出去让人看见的。但是,假如一个同学用浏览器访问http://localhost:8000/../app.js 怎么办捏? 不用太害怕,浏览器会自动干掉那两个作为父路径的点的。浏览器会把这个路径组装成http://localhost:8000/app.js的,这个文件在assets目录下不存在,返回404 Not Found。 但是文艺一点的同学会通过curl -ihttp://localhost:8000/../app.js 来访问。于是,悲剧了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
#
curl -i <a href="http://localhost:8000/../app.js"
rel="nofollow">http://localhost:8000/../app.js</a> HTTP/1.1
200 Ok Content-Type:
text/javascript Last-Modified:
Thu, 10 Nov 2011 17:16:51 GMT Expires:
Sat, 10 Nov 2012 04:59:27 GMT Cache-Control:
max-age=31536000 Connection:
keep-alive Transfer-Encoding:
chunked var PORT
= 8000; var http
= require( "http" ); var url
= require( "url" ); var fs
= require( "fs" ); var path
= require( "path" ); var mime
= require( "./mime" ).types; |
那么怎么办呢?暴力点的解决方案就是禁止父路径。
首先替换掉所有的..,然后调用path.normalize方法来处理掉不正常的/。
1
|
var realPath
= path.join( "assets" ,
path.normalize(pathname.replace(/\.\./g, "" ))); |
于是这个时候通过curl -i http://localhost:8000/../app.js 访问,/../app.js会被替换掉为//app.js。normalize方法会将//app.js返回为/app.js。再加上真实的assets,就被实际映射为assets/app.js。这个文件不存在,于是返回404。
于是搞定父路径问题。与浏览器的行为保持一致。
Welcome页的锦上添花
再来回忆一下Apache的常见行为。当进入一个目录路径的时候,会去寻找index.html页面,如果index.html文件不存在,则返回目录索引。目录索引这里我们暂不考虑,如果用户请求的路径是/结尾的,我们就自动为其添加上index.html文件。如果这个文件不存在,继续返回404错误。
如果用户请求了一个目录路径,而且没有带上/。那么我们为其添加上/index.html,再重新做解析。
那么不喜欢hardcode的你,肯定是要把这个文件配置进config.js啦。这样你就可以选择各种后缀作为welcome页面。
1
2
3
|
exports.Welcome
= { file: "index.html" }; |
那么第一步,为/结尾的请求,自动添加上”index.html”。
1
2
3
|
if (pathname.slice(-1)
=== "/" )
{ pathname
= pathname + config.Welcome.file; } |
第二步,如果请求了一个目录路径,并且没有以/结尾。那么我们需要做判断。如果当前读取的路径是目录,就需要添加上/和index.html
1
2
3
|
if (stats.isDirectory())
{ realPath
= path.join(realPath, "/" ,
config.Welcome.file); } |
由于我们目前的结构发生了一点点变化。所以需要重构一下函数。而且,fs.stat方法具有比fs.exsits方法更多的功能。我们直接替代掉它。
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
var server
= http.createServer( function (request,
response) { var pathname
= url.parse(request.url).pathname; if (pathname.slice(-1)
=== "/" )
{ pathname
= pathname + config.Welcome.file; } var realPath
= path.join( "assets" ,
path.normalize(pathname.replace(/\.\./g, "" ))); var pathHandle
= function (realPath)
{ fs.stat(realPath, function (err,
stats) { if (err)
{ response.writeHead(404, "Not
Found" ,
{ 'Content-Type' : 'text/plain' }); response.write( "This
request URL " +
pathname + "
was not found on this server." ); response.end(); } else { if (stats.isDirectory())
{ realPath
= path.join(realPath, "/" ,
config.Welcome.file); pathHandle(realPath); } else { var ext
= path.extname(realPath); ext
= ext ? ext.slice(1) : 'unknown' ; var contentType
= mime[ext] || "text/plain" ; response.setHeader( "Content-Type" ,
contentType); var lastModified
= stats.mtime.toUTCString(); var ifModifiedSince
= "If-Modified-Since" .toLowerCase(); response.setHeader( "Last-Modified" ,
lastModified); if (ext.match(config.Expires.fileMatch))
{ var expires
= new Date(); expires.setTime(expires.getTime()
+ config.Expires.maxAge * 1000); response.setHeader( "Expires" ,
expires.toUTCString()); response.setHeader( "Cache-Control" , "max-age=" +
config.Expires.maxAge); } if (request.headers[ifModifiedSince]
&& lastModified == request.headers[ifModifiedSince]) { response.writeHead(304, "Not
Modified" ); response.end(); } else { var raw
= fs.createReadStream(realPath); var acceptEncoding
= request.headers[ 'accept-encoding' ]
|| "" ; var matched
= ext.match(config.Compress.match); if (matched
&& acceptEncoding.match(/\bgzip\b/)) { response.writeHead(200, "Ok" ,
{ 'Content-Encoding' : 'gzip' }); raw.pipe(zlib.createGzip()).pipe(response); } else if (matched
&& acceptEncoding.match(/\bdeflate\b/)) { response.writeHead(200, "Ok" ,
{ 'Content-Encoding' : 'deflate' }); raw.pipe(zlib.createDeflate()).pipe(response); } else { response.writeHead(200, "Ok" ); raw.pipe(response); } } } } }); }; pathHandle(realPath); }); |
就这样。一个各方面都比较完整的静态文件服务器就这样打造完毕。
Range支持,搞定媒体断点支持
关于http1.1中的Range定义,可以参见这两篇文章:
- http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
- http://labs.apache.org/webarch/http/draft-fielding-http/p5-range.html
接下来,我将简单地介绍一下range的作用和其定义。
当用户在听一首歌的时候,如果听到一半(网络下载了一半),网络断掉了,用户需要继续听的时候,文件服务器不支持断点的话,则用户需要重新下载这个文件。而Range支持的话,客户端应该记录了之前已经读取的文件范围,网络恢复之后,则向服务器发送读取剩余Range的请求,服务端只需要发送客户端请求的那部分内容,而不用整个文件发送回客户端,以此节省网络带宽。
那么HTTP1.1规范的Range是怎样一个约定呢。
-
如果Server支持Range,首先就要告诉客户端,咱支持Range,之后客户端才可能发起带Range的请求。这里套用唐僧的一句话,你不说我怎么知道呢。
response.setHeader(‘Accept-Ranges’, ‘bytes’); - Server通过请求头中的Range: bytes=0-xxx来判断是否是做Range请求,如果这个值存在而且有效,则只发回请求的那部分文件内容,响应的状态码变成206,表示Partial Content,并设置Content-Range。如果无效,则返回416状态码,表明Request Range Not Satisfiable(http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.17 )。如果不包含Range的请求头,则继续通过常规的方式响应。
- 有必要对Range请求做一下解释。
1
2
3
4
5
6
|
ranges-specifier
= byte-ranges-specifier byte-ranges-specifier
= bytes-unit "=" byte-range- set byte-range- set =
1 #(
byte-range-spec | suffix-byte-range-spec ) byte-range-spec
= first-byte-pos "-" [last-byte-pos] first-byte-pos
= 1*DIGIT last-byte-pos
= 1*DIGIT |
上面这段定义来自w3定义的协议http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35。大致可以表述为Range: bytes=[start]-[end][,[start]-[end]]。简言之有以下几种情况:
- bytes=0-99,从0到99之间的数据字节。
- bytes=-100,文件的最后100个字节。
- bytes=100-,第100个字节开始之后的所有字节。
- bytes=0-99,200-299,从0到99之间的数据字节和200到299之间的数据字节。
那么,我们就开始实现吧。首先判断Range请求和检测其是否有效。为了保持代码干净,我们封装一个parseRange方法吧,这个方法属于util性质的,那么我们放进utils.js文件吧。
1
|
var utils
= require( "./utils" ); |
我们暂且不支持多区间吧。于是遇见逗号,就报416错误吧。
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
|
exports.parseRange
= function (str,
size) { if (str.indexOf( "," )
!= -1) { return ; } var range
= str.split( "-" ), start
= parseInt(range[0], 10), end
= parseInt(range[1], 10); //
Case: -100 if (isNaN(start))
{ start
= size - end; end
= size - 1; //
Case: 100- } else if (isNaN(end))
{ end
= size - 1; } //
Invalid if (isNaN(start)
|| isNaN(end) || start > end || end > size) { return ; } return {start:
start, end: end}; }; |
如果满足Range的条件,则为响应添加上Content-Range和修改掉Content-Lenth。
1
2
|
response.setHeader( "Content-Range" , "bytes
" +
range.start + "-" +
range.end + "/" +
stats.size); response.setHeader( "Content-Length" ,
(range.end - range.start + 1)); |
这里很荣幸的是Node的读文件流原生支持读取文件range。
var raw = fs.createReadStream(realPath, {“start”: range.start, “end”: range.end});
并且设置状态码为206。
由于选取Range之后,依然还是需要经过GZip的。于是代码已经有点面条的味道了。重构一下吧。于是代码大致如此:
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
30
31
32
|
var compressHandle
= function (raw,
statusCode, reasonPhrase) { var stream
= raw; var acceptEncoding
= request.headers[ 'accept-encoding' ]
|| "" ; var matched
= ext.match(config.Compress.match); if (matched
&& acceptEncoding.match(/\bgzip\b/)) { response.setHeader( "Content-Encoding" , "gzip" ); stream
= raw.pipe(zlib.createGzip()); } else if (matched
&& acceptEncoding.match(/\bdeflate\b/)) { response.setHeader( "Content-Encoding" , "deflate" ); stream
= raw.pipe(zlib.createDeflate()); } response.writeHead(statusCode,
reasonPhrase); stream.pipe(response); }; if (request.headers[ "range" ])
{ var range
= utils.parseRange(request.headers[ "range" ],
stats.size); if (range)
{ response.setHeader( "Content-Range" , "bytes
" +
range.start + "-" +
range.end + "/" +
stats.size); response.setHeader( "Content-Length" ,
(range.end - range.start + 1)); var raw
= fs.createReadStream(realPath, { "start" :
range.start, "end" :
range.end}); compressHandle(raw,
206, "Partial
Content" ); } else { response.removeHeader( "Content-Length" ); response.writeHead(416, "Request
Range Not Satisfiable" ); response.end(); } } else { var raw
= fs.createReadStream(realPath); compressHandle(raw,
200, "Ok" ); } |
通过curl –header “Range:0-20″ -i http://localhost:8000/index.html请求测试一番试试。
1
2
3
4
5
6
7
8
9
10
11
12
|
HTTP/1.1
206 Partial Content Server:
Node/V5 Accept-Ranges:
bytes Content-Type:
text/html Content-Length:
21 Last-Modified:
Fri, 11 Nov 2011 19:14:51 GMT Content-Range:
bytes 0-20/54 Connection:
keep-alive <html> <body> <h1>I |
index.html文件并没有被整个发送给客户端。这里之所以没有完全的21个字节,是因为\t和\r都各算一个字节。
再用curl –header “Range:0-100″ -i http://localhost:8000/index.html反向测试一下吧。
1
2
3
4
5
6
7
|
HTTP/1.1
416 Request Range Not Satisfiable Server:
Node/V5 Accept-Ranges:
bytes Content-Type:
text/html Last-Modified:
Fri, 11 Nov 2011 19:14:51 GMT Connection:
keep-alive Transfer-Encoding:
chunked |
嗯,要的就是这个效果。至此,Range支持完成,这个静态文件服务器支持一些流媒体文件,表示没有压力啦。
后记
由于本章的目的是完成一个纯静态的文件服务器,所以不需要涉及到cookie,session等动态服务器的特性。下一章会讲述如何打造一个动态服务器。
最后再附赠一个小技巧。看到别人家的服务器都响应一个:
1
|
Server:
nginx |
觉得老牛逼了。那么我们自己也搞一个吧。
1
|
response.setHeader( "Server" , "Node/V5" ); |
嗯。就这么简单。
全文的最终代码可以从这里下载: http://vdisk.weibo.com/s/15iUP
项目目前已经发布到github上,同学们可以持续关注此项目的进展。github地址是:https://github.com/JacksonTian/nodev5