用NodeJS打造你的静态文件服务器
发布于 12 年前 作者 JacksonTian 122203 次浏览 最后一次编辑是 8 年前 来自 分享

<h1><span class=“Apple-style-span” style=“font-size: 20px;”>前言</span></h1> <br/>在《The Node Beginner Book》的中文版(<a href=“http://nodebeginner.org/index-zh-cn.html”>http://nodebeginner.org/index-zh-cn.html</a>)发布之后,获得国内的好评。也有同学觉得这本书略薄,没有包含进阶式的例子。<a href=“http://www.weibo.com/n/otakustay”>@otakustay</a>同学说:“确实,我的想法是在这之上补一个简单的MVC框架和一个StaticFile+Mimetype+CacheControl机制,可以成为一个更全面的教程”。正巧的是目前我手里的V5项目有一些特殊性: <br/><ol> <br/> <li>项目大多数的文件都是属于静态文件,只有数据部分存在动态请求</li> <br/> <li>数据部分的请求都呈现为RESTful的特性</li> <br/></ol> <br/>那么我之前写的Node_CI框架跟V5搭配起来感觉就有那么一点点怪怪的。所以我决定改造Node_CI框架,使之更适合V5前端的使用。原有的Node_CI项目继续保留着,新开项目为V5Node,同时在改造这个框架的过程完成@otakustay 同学提到的几点进阶部分,也算是对我自己学习Node的总结。 <br/> <br/>这个项目主要包含的两个部分就是静态服务器和RESTful服务器。 <br/><h2>第一部分 静态文件服务器</h2> <br/>既是一个新的项目,那么创建v5node目录是应该的。既是一个Node应用,创建一个app.js文件也是应该的。 <br/> <br/>如果你有认真读完《The Node Beginner Book》或是看到过Nodejs官方网站上的那段经典代码,那么你对下面这段代码应当是非常不陌生的。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var http = require(“http”); <br/>http.createServer(function(request, response) { <br/> response.writeHead(200, {“Content-Type”: “text/plain”}); <br/> response.write(“Hello World”); <br/> response.end(); <br/>}).listen(8888);</pre> <br/>那么我们的app.js文件里的结构也很明确了。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var PORT = 8000; <br/> <br/>var http = require(‘http’); <br/>var server = http.createServer(function(request, response) { <br/>// TODO <br/>}); <br/> <br/>server.listen(PORT); <br/>console.log(“Server runing at port: " + PORT + “.”);</pre> <br/>因为当前要实现的功能是静态文件服务器,那么以Apache为例,让我们回忆一下静态文件服务器都有哪些功能。 <br/> <br/>浏览器发送URL,服务端解析URL,对应到硬盘上的文件。如果文件存在,返回200状态码,并发送文件到浏览器端;如果文件不存在,返回404状态码,发送一个404的文件到浏览器端。 <br/> <br/>以下两图是Apache经典的两种状态。 <br/> <br/><a href=“http://static.data.taobaocdn.com/up/nodeclub/2011/11/found.png”><img class=“alignnone size-medium wp-image-3905” title=“found” src=“http://static.data.taobaocdn.com/up/nodeclub/2011/11/found-300x176.png” alt=”" width=“300” height=“176” /></a><a href=“http://static.data.taobaocdn.com/up/nodeclub/2011/11/notfound.png”><img class=“alignnone size-medium wp-image-3906” title=“notfound” src=“http://static.data.taobaocdn.com/up/nodeclub/2011/11/notfound-300x176.png” alt="" width=“300” height=“176” /></a> <br/> <br/>现在cases已经明了,那么我们开始实现吧。 <br/><h3>实现路由</h3> <br/>路由部分的实现在《The Node Beginner Book》已经被描述过,此处不例外。 <br/> <br/>添加url模块是必要的。然后解析pathname。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var url = require(“url”); <br/>var pathname = url.parse(request.url).pathname;</pre> <br/>以下是实现代码: <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var server = http.createServer(function(request, response) { <br/> var pathname = url.parse(request.url).pathname; <br/> response.write(pathname); <br/> response.end(); <br/>});</pre> <br/>现在的代码是向浏览器端输出请求的路径,类似一个echo服务器。接下来我们为其添加输出对应文件的功能。 <br/><h3>读取静态文件</h3> <br/>为了不让用户在浏览器端通过请求/app.js查看到我们的代码,我们设定用户只能请求assets目录下的文件。服务器会将路径信息映射到assets目录。 <br/> <br/>涉及到了文件读取的这部分,自然不能避开fs(file system)这个模块。那么引入fs模块吧。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var fs = require(“fs”);</pre> <br/>同样,涉及到了路径处理,path模块也是需要的。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var path = require(“path”);</pre> <br/>我们通过path模块的path.exists方法来判断静态文件是否存在磁盘上。不存在我们直接响应给客户端404错误。 <br/> <br/>如果文件存在则调用fs.readFile方法读取文件。如果发生错误,我们响应给客户端500错误,表明存在内部错误。正常状态下则发送读取到的文件给客户端,表明200状态。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var server = http.createServer(function(request, response) { <br/> var pathname = url.parse(request.url).pathname; <br/> var realPath = “assets” + pathname; <br/> <br/> path.exists(realPath, function (exists) { <br/> if (!exists) { <br/> response.writeHead(404, {‘Content-Type’: ‘text/plain’}); <br/> response.write(“This request URL " + pathname + " was not found on this server.”); <br/> response.end(); <br/> } else { <br/> fs.readFile(realPath, “binary”, function(err, file) { <br/> if (err) { <br/> response.writeHead(500, {‘Content-Type’: ‘text/plain’}); <br/> response.end(err); <br/> } else { <br/> response.writeHead(200, {‘Content-Type’: ‘text/html’}); <br/> response.write(file, “binary”); <br/> response.end(); <br/> } <br/> }); <br/> } <br/> }); <br/>});</pre> <br/>以上这段简单的代码加上一个assets目录,就构成了我们最基本的静态文件服务器。 <br/> <br/>那么眼尖的你且看看,这个最基本的静态文件服务器存在哪些问题呢?答案是MIME类型支持。因为我们的服务器同时要存放html, css, js, png, gif, jpg等等文件。并非每一种文件的MIME类型都是text/html的。 <br/><h3>MIME类型支持</h3> <br/>像其他服务器一样,支持MIME的话,就得一张映射表。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>exports.types = { <br/> “css”: “text/css”, <br/> “gif”: “image/gif”, <br/> “html”: “text/html”, <br/> “ico”: “image/x-icon”, <br/> “jpeg”: “image/jpeg”, <br/> “jpg”: “image/jpeg”, <br/> “js”: “text/javascript”, <br/> “json”: “application/json”, <br/> “pdf”: “application/pdf”, <br/> “png”: “image/png”, <br/> “svg”: “image/svg+xml”, <br/> “swf”: “application/x-shockwave-flash”, <br/> “tiff”: “image/tiff”, <br/> “txt”: “text/plain”, <br/> “wav”: “audio/x-wav”, <br/> “wma”: “audio/x-ms-wma”, <br/> “wmv”: “video/x-ms-wmv”, <br/> “xml”: “text/xml” <br/>};</pre> <br/>以上代码另存在mime.js文件中。该文件仅仅只列举了一些常用的MIME类型,以文件后缀作为key,MIME类型为value。那么引入mime.js文件吧。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var mime = require("./mime").types;</pre> <br/>我们通过path.extname来获取文件的后缀名。由于extname返回值包含”.”,所以通过slice方法来剔除掉”.”,对于没有后缀名的文件,我们一律认为是unknown。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var ext = path.extname(realPath); <br/>ext = ext ? ext.slice(1) : ‘unknown’;</pre> <br/>接下来我们很容易得到真正的MIME类型了。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var ext = path.extname(realPath); <br/>ext = ext ? ext.slice(1) : ‘unknown’; <br/>var contentType = mime[ext] || “text/plain”; <br/>response.writeHead(200, {‘Content-Type’: contentType}); <br/>response.write(file, “binary”); <br/>response.end();</pre> <br/>对于未知的类型,我们一律返回text/plain类型。 <br/><h3>缓存支持/控制</h3> <br/>在MIME支持之后,静态文件服务器看起来已经很完美了。任何静态文件只要丢进assets目录之后就可以万事大吉不管了。看起来已经达到了Apache作为静态文件服务器的相同效果了。我们实现这样的服务器用的代码只有这么多行而已。是不是很简单呢? <br/> <br/>但是,我们发现用户在每次请求的时候,服务器每次都要调用fs.readFile方法去读取硬盘上的文件的。当服务器的请求量一上涨,硬盘IO会吃不消的。 <br/> <br/>在解决这个问题之前,我们有必要了解一番前端浏览器缓存的一些机制和提高性能的方案。 <br/><ol> <br/> <li>Gzip压缩文件可以减少响应的大小,能够达到节省带宽的目的。</li> <br/> <li>浏览器缓存中存有文件副本的时候,不能确定有效的时候,会生成一个条件get请求 <br/><ol> <br/> <li>在请求的头中会包含 If-Modified-Since</li> <br/> <li>如果服务器端文件在这个时间后发生过修改,则发送整个文件给前端。</li> <br/> <li>如果没有修改,则返回304状态码。并不发送整个文件给前端。</li> <br/> <li>另外一种判断机制是ETag。在此并不讨论。</li> <br/> <li>如果副本有效,这个get请求都会省掉。判断有效的最主要的方法是服务端响应的时候带上Expires的头。 <br/><ol> <br/> <li>浏览器会判断Expires头,直到制定的日期过期,才会发起新的请求。</li> <br/> <li>另一个可以达到相同目的的方法是返回Cache-Control: max-age=xxxx。</li> <br/></ol> <br/></li> <br/></ol> <br/></li> <br/></ol> <br/>欲了解更多缓存机制,请参见Steve Sounders著作的《高性能网站建设指南》。 <br/> <br/>为了简化问题,我们只做如下这几件事情: <br/><ol> <br/> <li>为指定几种后缀的文件,在响应时添加Expires头和Cache-Control: max-age头。超时日期设置为1年。</li> <br/> <li>由于这是静态文件服务器,为所有请求,响应时返回Last-Modified头。</li> <br/> <li>为带If-Modified-Since的请求头,做日期检查,如果没有修改,则返回304。若修改,则返回文件。</li> <br/></ol> <br/>对于以上的静态文件服务器,Node给的响应头是十分简单的: <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>Connection: keep-alive <br/>Content-Type: text/html <br/>Transfer-Encoding: chunked</pre> <br/>那么我们搞起吧。 <br/> <br/>对于指定后缀文件和过期日期,为了保证可配置。那么建立一个config.js文件是应该的。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>exports.Expires = { <br/> fileMatch: /^(gif|png|jpg|js|css)$/ig, <br/> maxAge: 606024365 <br/>};</pre> <br/>引入config.js文件。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var config = require("./config");</pre> <br/>我们在相应之前判断后缀名是否符合我们要添加过期时间头的条件。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var ext = path.extname(realPath); <br/>ext = ext ? ext.slice(1) : ‘unknown’; <br/> <br/>if (ext.match(config.Expires.fileMatch)) { <br/> var expires = new Date(); <br/> expires.setTime(expires.getTime() + config.Expires.maxAge * 1000); <br/> response.setHeader(“Expires”, expires.toUTCString()); <br/> response.setHeader(“Cache-Control”, “max-age=” + config.Expires.maxAge); <br/>}</pre> <br/>这次的响应头中多了两个header。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>Cache-Control: max-age=31536000 <br/>Connection: keep-alive <br/>Content-Type: image/png <br/>Expires: Fri, 09 Nov 2012 12:55:41 GMT <br/>Transfer-Encoding: chunked</pre> <br/>浏览器在发送请求之前由于检测到Cache-Control和Expires(Cache-Control的优先级高于Expires,但有的浏览器不支持Cache-Control,这时采用Expires),如果没有过期,则不会发送请求,而直接从缓存中读取文件。 <br/> <br/>接下来我们为所有请求的响应都添加Last-Modified头。 <br/> <br/>读取文件的最后修改时间是通过fs模块的fs.stat()方法来实现的。关于stat的详细介绍请参见此处:<a href=“http://www.cnitblog.com/guopingleee/archive/2008/11/13/51411.aspx”>http://www.cnitblog.com/guopingleee/archive/2008/11/13/51411.aspx</a> <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>fs.stat(realPath, function (err, stat) { <br/> var lastModified = stat.mtime.toUTCString(); <br/> response.setHeader(“Last-Modified”, lastModified); <br/>});</pre> <br/>我们同时也要检测浏览器是否发送了If-Modified-Since请求头。如果发送而且跟文件的修改时间相同的话,我们返回304状态。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) { <br/> response.writeHead(304, “Not Modified”); <br/> response.end(); <br/>}</pre> <br/>如果没有发送或者跟磁盘上的文件修改时间不相符合,则发送回磁盘上的最新文件。 <br/> <br/>此时的代码大致如下: <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var server = http.createServer(function(request, response) { <br/> var pathname = url.parse(request.url).pathname;console.log(pathname); <br/> var realPath = path.join(“assets”, pathname); <br/> <br/> path.exists(realPath, function (exists) { <br/> if (!exists) { <br/> response.writeHead(404, “Not Found”, {‘Content-Type’: ‘text/plain’}); <br/> response.write(“This request URL " + pathname + " was not found on this server.”); <br/> response.end(); <br/> } else { <br/> var ext = path.extname(realPath); <br/> ext = ext ? ext.slice(1) : ‘unknown’; <br/> var contentType = mime[ext] || “text/plain”; <br/> response.setHeader(“Content-Type”, contentType); <br/> <br/> fs.stat(realPath, function (err, stat) { <br/> var lastModified = stat.mtime.toUTCString(); <br/> var ifModifiedSince = “If-Modified-Since”.toLowerCase(); <br/> response.setHeader(“Last-Modified”, lastModified); <br/> <br/> if (ext.match(config.Expires.fileMatch)) { <br/> var expires = new Date(); <br/> expires.setTime(expires.getTime() + config.Expires.maxAge * 1000); <br/> response.setHeader(“Expires”, expires.toUTCString()); <br/> response.setHeader(“Cache-Control”, “max-age=” + config.Expires.maxAge); <br/> } <br/> <br/> if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) { <br/> response.writeHead(304, “Not Modified”); <br/> response.end(); <br/> } else { <br/> fs.readFile(realPath, “binary”, function(err, file) { <br/> if (err) { <br/> response.writeHead(500, “Internal Server Error”, {‘Content-Type’: ‘text/plain’}); <br/> response.end(err); <br/> } else { <br/> response.writeHead(200, “Ok”); <br/> response.write(file, “binary”); <br/> response.end(); <br/> } <br/> }); <br/> } <br/> }); <br/> } <br/> }); <br/>});</pre> <br/>通过Expires和Last-Modified两个方案以及与浏览器之间的通力合作,会节省相当大的一部分网络流量,同时也会降低部分硬盘IO的请求。如果在这之前还存在CDN的话,整个solution就比较完美了。 <br/> <br/>由于Expires和Max-Age都是由浏览器来进行判断的,如果判断成功,http请求都不会发送到服务端的,这里只能通过fiddler和浏览器配合进行测试。但是Last-Modified却是可以通过curl来进行测试的。 <br/><pre class=“brush: bash; gutter: true; first-line: 1”>curl --header “If-Modified-Since: Fri, 11 Nov 2011 19:14:51 GMT” -i http://localhost:8000</pre> <br/>结果: <br/><pre class=“brush: bash; gutter: true; first-line: 1”>HTTP/1.1 304 Not Modified <br/>Content-Type: text/html <br/>Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT <br/>Connection: keep-alive</pre> <br/>注意,我们看到这个304请求的响应是不带body信息的。所以,达到我们节省带宽的需求。只需几行代码,就可以替老板省下许多的带宽费用,咱们程序员是有力量的。 <br/> <br/>但是,貌似我们有提到gzip这样的东西。对于CSS,JS等文件如果不采用gzip的话,还是会浪费掉部分网络带宽。那么接下来把gzip搞起吧。 <br/><h3>GZip启用</h3> <br/>如果你是前端达人,你应该是知道YUI Compressor或Google Closure Complier这样的压缩工具的。在这基础上,再进行gzip压缩,则会减少很多的网络流量。那么,我们看看Node中,怎么把gzip搞起类。 <br/> <br/>要用到gzip,就需要zlib模块,该模块在Node的0.5.8版本开始原生支持。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var zlib = require(“zlib”);</pre> <br/>对于图片一类的文件,不需要进行gzip压缩,所以我们在config.js中配置一个启用压缩的列表。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>exports.Compress = { <br/> match: /css|js|html/ig <br/>};</pre> <br/>这里为了防止大文件,也为了满足zlib模块的调用模式,将读取文件改为流的形式进行读取。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var raw = fs.createReadStream(realPath); <br/>var acceptEncoding = request.headers[‘accept-encoding’] || “”; <br/>var matched = ext.match(config.Compress.match); <br/> <br/>if (matched && acceptEncoding.match(/\bgzip\b/)) { <br/> response.writeHead(200, “Ok”, {‘Content-Encoding’: ‘gzip’}); <br/> raw.pipe(zlib.createGzip()).pipe(response); <br/>} else if (matched && acceptEncoding.match(/\bdeflate\b/)) { <br/> response.writeHead(200, “Ok”, {‘Content-Encoding’: ‘deflate’}); <br/> raw.pipe(zlib.createDeflate()).pipe(response); <br/>} else { <br/> response.writeHead(200, “Ok”); <br/> raw.pipe(response); <br/>}</pre> <br/>对于支持压缩的文件格式以及浏览器端接受gzip或deflate压缩,我们调用压缩。若不,则管道方式转发给response。 <br/> <br/>启用压缩其实就这么简单。如果你有fiddler的话,可以监听一下请求,会看到被压缩的请求。 <br/> <br/>最终app.js文件的代码如下: <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var server = http.createServer(function(request, response) { <br/> var pathname = url.parse(request.url).pathname; <br/> var realPath = path.join(“assets”, pathname); <br/> <br/> path.exists(realPath, function (exists) { <br/> if (!exists) { <br/> response.writeHead(404, “Not Found”, {‘Content-Type’: ‘text/plain’}); <br/> response.write(“This request URL " + pathname + " was not found on this server.”); <br/> response.end(); <br/> } else { <br/> var ext = path.extname(realPath); <br/> ext = ext ? ext.slice(1) : ‘unknown’; <br/> var contentType = mime[ext] || “text/plain”; <br/> response.setHeader(“Content-Type”, contentType); <br/> <br/> fs.stat(realPath, function (err, stat) { <br/> var lastModified = stat.mtime.toUTCString(); <br/> var ifModifiedSince = “If-Modified-Since”.toLowerCase(); <br/> response.setHeader(“Last-Modified”, lastModified); <br/> <br/> if (ext.match(config.Expires.fileMatch)) { <br/> var expires = new Date(); <br/> expires.setTime(expires.getTime() + config.Expires.maxAge * 1000); <br/> response.setHeader(“Expires”, expires.toUTCString()); <br/> response.setHeader(“Cache-Control”, “max-age=” + config.Expires.maxAge); <br/> } <br/> <br/> if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) { <br/> response.writeHead(304, “Not Modified”); <br/> response.end(); <br/> } else { <br/> var raw = fs.createReadStream(realPath); <br/> var acceptEncoding = request.headers[‘accept-encoding’] || “”; <br/> var matched = ext.match(config.Compress.match); <br/> <br/> if (matched && acceptEncoding.match(/\bgzip\b/)) { <br/> response.writeHead(200, “Ok”, {‘Content-Encoding’: ‘gzip’}); <br/> raw.pipe(zlib.createGzip()).pipe(response); <br/> } else if (matched && acceptEncoding.match(/\bdeflate\b/)) { <br/> response.writeHead(200, “Ok”, {‘Content-Encoding’: ‘deflate’}); <br/> raw.pipe(zlib.createDeflate()).pipe(response); <br/> } else { <br/> response.writeHead(200, “Ok”); <br/> raw.pipe(response); <br/> } <br/> } <br/> }); <br/> } <br/> }); <br/>});</pre> <br/><pre class=“brush: javascript; gutter: true; first-line: 1”></pre> <br/> <br/>  <br/> <br/><h3>安全问题</h3> <br/> <br/>我们搞了一大堆的事情,但是安全方面也不能少。想想哪一个地方是最容易出问题的? 我们发现上面的这段代码写得还是有点纠结的,通常这样纠结的代码我是不愿意拿出去让人看见的。但是,假如一个同学用浏览器访问http://localhost:8000/…/app.js 怎么办捏? 不用太害怕,浏览器会自动干掉那两个作为父路径的点的。浏览器会把这个路径组装成http://localhost:8000/app.js的,这个文件在assets目录下不存在,返回404 Not Found。 但是文艺一点的同学会通过curl -i http://localhost:8000/…/app.js 来访问。于是,悲剧了。 <br/> <br/><pre class=“brush: javascript; gutter: true; first-line: 1”># curl -i http://localhost:8000/…/app.js <br/>HTTP/1.1 200 Ok <br/>Content-Type: text/javascript <br/>Last-Modified: Thu, 10 Nov 2011 17:16:51 GMT <br/>Expires: Sat, 10 Nov 2012 04:59:27 GMT <br/>Cache-Control: max-age=31536000 <br/>Connection: keep-alive <br/>Transfer-Encoding: chunked <br/> <br/>var PORT = 8000; <br/>var http = require(“http”); <br/>var url = require(“url”); <br/>var fs = require(“fs”); <br/>var path = require(“path”); <br/>var mime = require("./mime").types;</pre> <br/>那么怎么办呢?暴力点的解决方案就是禁止父路径。 <br/> <br/>首先替换掉所有的…,然后调用path.normalize方法来处理掉不正常的/。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var realPath = path.join(“assets”, path.normalize(pathname.replace(/…/g, “”)));</pre> <br/>于是这个时候通过curl -i http://localhost:8000/…/app.js 访问,/…/app.js会被替换掉为//app.js。normalize方法会将//app.js返回为/app.js。再加上真实的assets,就被实际映射为assets/app.js。这个文件不存在,于是返回404。 <br/> <br/>于是搞定父路径问题。与浏览器的行为保持一致。 <br/><h3>Welcome页的锦上添花</h3> <br/>再来回忆一下Apache的常见行为。当进入一个目录路径的时候,会去寻找index.html页面,如果index.html文件不存在,则返回目录索引。目录索引这里我们暂不考虑,如果用户请求的路径是/结尾的,我们就自动为其添加上index.html文件。如果这个文件不存在,继续返回404错误。 <br/> <br/>如果用户请求了一个目录路径,而且没有带上/。那么我们为其添加上/index.html,再重新做解析。 <br/> <br/>那么不喜欢hardcode的你,肯定是要把这个文件配置进config.js啦。这样你就可以选择各种后缀作为welcome页面。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>exports.Welcome = { <br/> file: “index.html” <br/>};</pre> <br/>那么第一步,为/结尾的请求,自动添加上”index.html”。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>if (pathname.slice(-1) === “/”) { <br/> pathname = pathname + config.Welcome.file; <br/>}</pre> <br/>第二步,如果请求了一个目录路径,并且没有以/结尾。那么我们需要做判断。如果当前读取的路径是目录,就需要添加上/和index.html <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>if (stats.isDirectory()) { <br/> realPath = path.join(realPath, “/”, config.Welcome.file); <br/>}</pre> <br/>由于我们目前的结构发生了一点点变化。所以需要重构一下函数。而且,fs.stat方法具有比fs.exsits方法更多的功能。我们直接替代掉它。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var server = http.createServer(function(request, response) { <br/>var pathname = url.parse(request.url).pathname; <br/> if (pathname.slice(-1) === “/”) { <br/> pathname = pathname + config.Welcome.file; <br/> } <br/> var realPath = path.join(“assets”, path.normalize(pathname.replace(/…/g, “”))); <br/> <br/> var pathHandle = function (realPath) { <br/> fs.stat(realPath, function (err, stats) { <br/> if (err) { <br/> response.writeHead(404, “Not Found”, {‘Content-Type’: ‘text/plain’}); <br/> response.write(“This request URL " + pathname + " was not found on this server.”); <br/> response.end(); <br/> } else { <br/> if (stats.isDirectory()) { <br/> realPath = path.join(realPath, “/”, config.Welcome.file); <br/> pathHandle(realPath); <br/> } else { <br/> var ext = path.extname(realPath); <br/> ext = ext ? ext.slice(1) : ‘unknown’; <br/> var contentType = mime[ext] || “text/plain”; <br/> response.setHeader(“Content-Type”, contentType); <br/> <br/> var lastModified = stats.mtime.toUTCString(); <br/> var ifModifiedSince = “If-Modified-Since”.toLowerCase(); <br/> response.setHeader(“Last-Modified”, lastModified); <br/> <br/> if (ext.match(config.Expires.fileMatch)) { <br/> var expires = new Date(); <br/> expires.setTime(expires.getTime() + config.Expires.maxAge * 1000); <br/> response.setHeader(“Expires”, expires.toUTCString()); <br/> response.setHeader(“Cache-Control”, “max-age=” + config.Expires.maxAge); <br/> } <br/> <br/> if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) { <br/> response.writeHead(304, “Not Modified”); <br/> response.end(); <br/> } else { <br/> var raw = fs.createReadStream(realPath); <br/> var acceptEncoding = request.headers[‘accept-encoding’] || “”; <br/> var matched = ext.match(config.Compress.match); <br/> <br/> if (matched && acceptEncoding.match(/\bgzip\b/)) { <br/> response.writeHead(200, “Ok”, {‘Content-Encoding’: ‘gzip’}); <br/> raw.pipe(zlib.createGzip()).pipe(response); <br/> } else if (matched && acceptEncoding.match(/\bdeflate\b/)) { <br/> response.writeHead(200, “Ok”, {‘Content-Encoding’: ‘deflate’}); <br/> raw.pipe(zlib.createDeflate()).pipe(response); <br/> } else { <br/> response.writeHead(200, “Ok”); <br/> raw.pipe(response); <br/> } <br/> } <br/> } <br/> } <br/> }); <br/> }; <br/> <br/> pathHandle(realPath); <br/>});</pre> <br/>就这样。一个各方面都比较完整的静态文件服务器就这样打造完毕。 <br/><h3>Range支持,搞定媒体断点支持</h3> <br/>关于http1.1中的Range定义,可以参见这两篇文章: <br/><ul> <br/> <li><a href=“http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html”>http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html</a></li> <br/> <li><a href=“http://labs.apache.org/webarch/http/draft-fielding-http/p5-range.html”>http://labs.apache.org/webarch/http/draft-fielding-http/p5-range.html</a></li> <br/></ul> <br/>接下来,我将简单地介绍一下range的作用和其定义。 <br/> <br/>当用户在听一首歌的时候,如果听到一半(网络下载了一半),网络断掉了,用户需要继续听的时候,文件服务器不支持断点的话,则用户需要重新下载这个文件。而Range支持的话,客户端应该记录了之前已经读取的文件范围,网络恢复之后,则向服务器发送读取剩余Range的请求,服务端只需要发送客户端请求的那部分内容,而不用整个文件发送回客户端,以此节省网络带宽。 <br/> <br/>那么HTTP1.1规范的Range是怎样一个约定呢。 <br/><ol> <br/> <li>如果Server支持Range,首先就要告诉客户端,咱支持Range,之后客户端才可能发起带Range的请求。这里套用唐僧的一句话,你不说我怎么知道呢。 <br/>response.setHeader(‘Accept-Ranges’, ‘bytes’);</li> <br/> <li>Server通过请求头中的Range: bytes=0-xxx来判断是否是做Range请求,如果这个值存在而且有效,则只发回请求的那部分文件内容,响应的状态码变成206,表示Partial Content,并设置Content-Range。如果无效,则返回416状态码,表明Request Range Not Satisfiable(<a href=“http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html%23sec10.4.17”>http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.17</a> )。如果不包含Range的请求头,则继续通过常规的方式响应。</li> <br/> <li>有必要对Range请求做一下解释。</li> <br/></ol> <br/><pre class=“brush: bash; gutter: true; first-line: 1”>ranges-specifier = byte-ranges-specifier <br/>byte-ranges-specifier = bytes-unit “=” byte-range-set <br/>byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec ) <br/>byte-range-spec = first-byte-pos “-” [last-byte-pos] <br/>first-byte-pos = 1DIGIT <br/>last-byte-pos = 1*DIGIT</pre> <br/>上面这段定义来自w3定义的协议<a href=“http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35”>http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35</a>。大致可以表述为Range: bytes=[start]-[end][,[start]-[end]]。简言之有以下几种情况: <br/><ul> <br/> <li>bytes=0-99,从0到99之间的数据字节。</li> <br/> <li>bytes=-100,文件的最后100个字节。</li> <br/> <li>bytes=100-,第100个字节开始之后的所有字节。</li> <br/> <li>bytes=0-99,200-299,从0到99之间的数据字节和200到299之间的数据字节。</li> <br/></ul> <br/>那么,我们就开始实现吧。首先判断Range请求和检测其是否有效。为了保持代码干净,我们封装一个parseRange方法吧,这个方法属于util性质的,那么我们放进utils.js文件吧。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var utils = require("./utils");</pre> <br/>我们暂且不支持多区间吧。于是遇见逗号,就报416错误吧。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>exports.parseRange = function (str, size) { <br/> if (str.indexOf(",") != -1) { <br/> return; <br/> } <br/> <br/> var range = str.split("-"), <br/> start = parseInt(range[0], 10), <br/> end = parseInt(range[1], 10); <br/> <br/> // Case: -100 <br/> if (isNaN(start)) { <br/> start = size - end; <br/> end = size - 1; <br/> // Case: 100- <br/> } else if (isNaN(end)) { <br/> end = size - 1; <br/> } <br/> <br/> // Invalid <br/> if (isNaN(start) || isNaN(end) || start > end || end > size) { <br/> return; <br/> } <br/> <br/> return {start: start, end: end}; <br/>};</pre> <br/>如果满足Range的条件,则为响应添加上Content-Range和修改掉Content-Lenth。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>response.setHeader(“Content-Range”, “bytes " + range.start + “-” + range.end + “/” + stats.size); <br/>response.setHeader(“Content-Length”, (range.end - range.start + 1));</pre> <br/>这里很荣幸的是Node的读文件流原生支持读取文件range。 <br/> <br/>var raw = fs.createReadStream(realPath, {“start”: range.start, “end”: range.end}); <br/> <br/>并且设置状态码为206。 <br/> <br/>由于选取Range之后,依然还是需要经过GZip的。于是代码已经有点面条的味道了。重构一下吧。于是代码大致如此: <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>var compressHandle = function (raw, statusCode, reasonPhrase) { <br/> var stream = raw; <br/> var acceptEncoding = request.headers[‘accept-encoding’] || “”; <br/> var matched = ext.match(config.Compress.match); <br/> <br/> if (matched && acceptEncoding.match(/\bgzip\b/)) { <br/> response.setHeader(“Content-Encoding”, “gzip”); <br/> stream = raw.pipe(zlib.createGzip()); <br/> } else if (matched && acceptEncoding.match(/\bdeflate\b/)) { <br/> response.setHeader(“Content-Encoding”, “deflate”); <br/> stream = raw.pipe(zlib.createDeflate()); <br/> } <br/> response.writeHead(statusCode, reasonPhrase); <br/> stream.pipe(response); <br/> }; <br/> <br/>if (request.headers[“range”]) { <br/> var range = utils.parseRange(request.headers[“range”], stats.size); <br/> if (range) { <br/> response.setHeader(“Content-Range”, “bytes " + range.start + “-” + range.end + “/” + stats.size); <br/> response.setHeader(“Content-Length”, (range.end - range.start + 1)); <br/> var raw = fs.createReadStream(realPath, {“start”: range.start, “end”: range.end}); <br/> compressHandle(raw, 206, “Partial Content”); <br/> } else { <br/> response.removeHeader(“Content-Length”); <br/> response.writeHead(416, “Request Range Not Satisfiable”); <br/> response.end(); <br/> } <br/>} else { <br/> var raw = fs.createReadStream(realPath); <br/> compressHandle(raw, 200, “Ok”); <br/>}</pre> <br/>通过curl --header “Range:0-20” -i http://localhost:8000/index.html请求测试一番试试。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>HTTP/1.1 206 Partial Content <br/>Server: Node/V5 <br/>Accept-Ranges: bytes <br/>Content-Type: text/html <br/>Content-Length: 21 <br/>Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT <br/>Content-Range: bytes 0-20/54 <br/>Connection: keep-alive <br/> <br/><html> <br/><body> <br/><h1>I</pre> <br/>  <br/> <br/>index.html文件并没有被整个发送给客户端。这里之所以没有完全的21个字节,是因为\t和\r都各算一个字节。 <br/> <br/>再用curl --header “Range:0-100” -i http://localhost:8000/index.html反向测试一下吧。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>HTTP/1.1 416 Request Range Not Satisfiable <br/>Server: Node/V5 <br/>Accept-Ranges: bytes <br/>Content-Type: text/html <br/>Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT <br/>Connection: keep-alive <br/>Transfer-Encoding: chunked</pre> <br/>嗯,要的就是这个效果。至此,Range支持完成,这个静态文件服务器支持一些流媒体文件,表示没有压力啦。 <br/><h3>后记</h3> <br/>由于本章的目的是完成一个纯静态的文件服务器,所以不需要涉及到cookie,session等动态服务器的特性。下一章会讲述如何打造一个动态服务器。 <br/> <br/>最后再附赠一个小技巧。看到别人家的服务器都响应一个: <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>Server: nginx</pre> <br/>觉得老牛逼了。那么我们自己也搞一个吧。 <br/><pre class=“brush: javascript; gutter: true; first-line: 1”>response.setHeader(“Server”, “Node/V5”);</pre> <br/>嗯。就这么简单。 <br/> <br/>全文的最终代码可以从这里下载: <a href=“http://vdisk.weibo.com/s/15iUP”>http://vdisk.weibo.com/s/15iUP</a> <br/> <br/>项目目前已经发布到github上,同学们可以持续关注此项目的进展。github地址是:<a href=“https://github.com/JacksonTian/ping” target=”_blank”>https://github.com/JacksonTian/ping</a> <br/> <br/>  <br/> <br/>

57 回复

太帅了,好棒啊! <br/> <br/>唉,近两天安装npm一直没成功,无论是window还是ubuntu,我都快失去信心了~

这种性能不能和用sendfile的比吧?

用node的fs.createReadStream的方式,已经比较令人满意了。关于sendfile,这个api看起来还不是一个public的api,还不清楚stream的形式底层是否调用了sendfile。有详细资料后,再讨论这个问题。

写的很用心,赞一个. <br/> <br/>=== <br/>stream的形式底层是否调用了sendfile <br/> <br/> <br/>stream 仅有 “read” 的语义,sendfile 是 “read” + ”send“ <br/>nodejs 的 readStream 就是在另外的线程池中执行 unix 的read 哦 <br/>和sendfile没得关系的

文章写的很好啊,通俗易懂,很实用。 <br/>我知道我要测试什么了,哈哈!

文章写的很好,步步深入,通俗易懂。。。 <br/>真是深入浅出。。。顶!期待下文。。。

[…] 完整的靜態檔案伺服器範例 包含 mime 分割 stream… […]

[…] 请君移步:http://cnodejs.org/blog/?p=3904 此条目是由 梅花Q 发表在 爱写代码 分类目录的。将固定链接加入收藏夹。 […]

在浏览器中response中文显示乱码如何解决啊?

保持所有的编码一致就可以。推荐 utf-8

使用"child_process"模块的exec: <br/> <br/>function start(response) { <br/> console.log(“Request handler ‘start’ was called.”); <br/> exec(“netstat -a”,{ encoding: ‘utf8’,timeout: 60000 }, <br/> function (error, stdout, stderr) { <br/> response.writeHead(200, {‘Content-Type’: ‘text/plain;charset=UTF-8’}); <br/> response.write(stdout); <br/> response.end(); <br/> }); <br/>} <br/> <br/>中文显示是乱码,请教你啊,是什么原因呢?

[…] 推荐一篇本技术的好文章: 用NodeJS打造你的静态文件服务器 发表评论 | Trackback […]

[…] 嗯,在写完几个简单的控制器和模板之后,我没有往m层走,而是转向去完成一个node版本的静态服务器,通过朴灵同学的这篇文章可以简单了解实现技术,我就不多说了。。最开始我实现的和他差不多,看过他的之后觉得他的那种方法更好。貌似是从来外的书上翻译来的……?看看你就知道了。http://cnodejs.org/blog/?p=3904 […]

[…] 嗯,在写完几个简单的控制器和模板之后,我没有往m层走,而是转向去完成一个node版本的静态服务器,通过朴灵同学的这篇文章可以简单了解实现技术,我就不多说了。。最开始我实现的和他差不多,看过他的之后觉得他的那种方法更好。貌似是从来外的书上翻译来的……?看看你就知道了。http://cnodejs.org/blog/?p=3904 […]

[…] 本文转载自CNode社区田永强的文章。 […]

赞,非常值得学习!

写的真好,由浅入深,很清晰,让人了解每部分代码是怎么从构造出来,添加进去的

好文!! <br/> <br/>一个request就够研究的啦。 <br/> <br/>Server running at http://127.0.0.1:8124/ <br/>{ socket: <br/> { _handle: <br/> { writeQueueSize: 0, <br/> socket: [Circular], <br/> onread: [Function: onread] }, <br/> _pendingWriteReqs: 2, <br/> _flags: 0, <br/> _connectQueueSize: 0, <br/> destroyed: false, <br/> bytesRead: 470, <br/> bytesWritten: 137, <br/> allowHalfOpen: true, <br/> writable: true, <br/> readable: true, <br/> server: <br/> { connections: 2, <br/> allowHalfOpen: true, <br/> _handle: [Object], <br/> _events: [Object], <br/> httpAllowHalfOpen: false }, <br/> ondrain: [Function], <br/> _idleTimeout: 120000, <br/> _idleNext: <br/> { _handle: [Object], <br/> _pendingWriteReqs: 0, <br/> _flags: 0, <br/> _connectQueueSize: 0, <br/> destroyed: false, <br/> bytesRead: 0, <br/> bytesWritten: 0, <br/> allowHalfOpen: true, <br/> writable: true, <br/> readable: true, <br/> server: [Object], <br/> ondrain: [Function], <br/> _idleTimeout: 120000, <br/> _idleNext: [Object], <br/> _idlePrev: [Circular], <br/> _idleStart: Thu, 15 Dec 2011 05:34:11 GMT, <br/> _events: [Object], <br/> ondata: [Function], <br/> onend: [Function] }, <br/> _idlePrev: <br/> { _idleNext: [Circular], <br/> _idlePrev: [Object], <br/> ontimeout: [Function] }, <br/> _idleStart: Thu, 15 Dec 2011 05:34:11 GMT, <br/> _events: { timeout: [Function], error: [Function], close: [Function] }, <br/> ondata: [Function], <br/> onend: [Function], <br/> _httpMessage: null }, <br/> connection: <br/> { _handle: <br/> { writeQueueSize: 0, <br/> socket: [Circular], <br/> onread: [Function: onread] }, <br/> _pendingWriteReqs: 2, <br/> _flags: 0, <br/> _connectQueueSize: 0, <br/> destroyed: false, <br/> bytesRead: 470, <br/> bytesWritten: 137, <br/> allowHalfOpen: true, <br/> writable: true, <br/> readable: true, <br/> server: <br/> { connections: 2, <br/> allowHalfOpen: true, <br/> _handle: [Object], <br/> _events: [Object], <br/> httpAllowHalfOpen: false }, <br/> ondrain: [Function], <br/> _idleTimeout: 120000, <br/> _idleNext: <br/> { _handle: [Object], <br/> _pendingWriteReqs: 0, <br/> _flags: 0, <br/> _connectQueueSize: 0, <br/> destroyed: false, <br/> bytesRead: 0, <br/> bytesWritten: 0, <br/> allowHalfOpen: true, <br/> writable: true, <br/> readable: true, <br/> server: [Object], <br/> ondrain: [Function], <br/> _idleTimeout: 120000, <br/> _idleNext: [Object], <br/> _idlePrev: [Circular], <br/> _idleStart: Thu, 15 Dec 2011 05:34:11 GMT, <br/> _events: [Object], <br/> ondata: [Function], <br/> onend: [Function] }, <br/> _idlePrev: <br/> { _idleNext: [Circular], <br/> _idlePrev: [Object], <br/> ontimeout: [Function] }, <br/> _idleStart: Thu, 15 Dec 2011 05:34:11 GMT, <br/> _events: { timeout: [Function], error: [Function], close: [Function] }, <br/> ondata: [Function], <br/> onend: [Function], <br/> _httpMessage: null }, <br/> httpVersion: ‘1.1’, <br/> complete: false, <br/> headers: <br/> { host: ‘127.0.0.1:8124’, <br/> connection: ‘keep-alive’, <br/> ‘cache-control’: ‘max-age=0’, <br/> ‘user-agent’: ‘Mozilla/5.0 (Windows NT 6.1) AppleWebKit/535.2 (KHTML, lik <br/>Gecko) Chrome/15.0.874.121 Safari/535.2’, <br/> accept: ‘text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8’ <br/> ‘accept-encoding’: ‘gzip,deflate,sdch’, <br/> ‘accept-language’: ‘zh-CN,zh;q=0.8’, <br/> ‘accept-charset’: ‘GBK,utf-8;q=0.7,*;q=0.3’ }, <br/> trailers: {}, <br/> readable: true, <br/> url: ‘/sdfsdf/sdfsdfs/fsfsds/fsdfds/fsdfsd/fsdfsdf/fsdfs/fsfds?sdfds=fsdfsd& <br/>dsf=fsdfs2324’, <br/> method: ‘GET’, <br/> statusCode: null, <br/> client: <br/> { _handle: <br/> { writeQueueSize: 0, <br/> socket: [Circular], <br/> onread: [Function: onread] }, <br/> _pendingWriteReqs: 2, <br/> _flags: 0, <br/> _connectQueueSize: 0, <br/> destroyed: false, <br/> bytesRead: 470, <br/> bytesWritten: 137, <br/> allowHalfOpen: true, <br/> writable: true, <br/> readable: true, <br/> server: <br/> { connections: 2, <br/> allowHalfOpen: true, <br/> _handle: [Object], <br/> _events: [Object], <br/> httpAllowHalfOpen: false }, <br/> ondrain: [Function], <br/> _idleTimeout: 120000, <br/> _idleNext: <br/> { _handle: [Object], <br/> _pendingWriteReqs: 0, <br/> _flags: 0, <br/> _connectQueueSize: 0, <br/> destroyed: false, <br/> bytesRead: 0, <br/> bytesWritten: 0, <br/> allowHalfOpen: true, <br/> writable: true, <br/> readable: true, <br/> server: [Object], <br/> ondrain: [Function], <br/> _idleTimeout: 120000, <br/> _idleNext: [Object], <br/> _idlePrev: [Circular], <br/> _idleStart: Thu, 15 Dec 2011 05:34:11 GMT, <br/> _events: [Object], <br/> ondata: [Function], <br/> onend: [Function] }, <br/> _idlePrev: <br/> { _idleNext: [Circular], <br/> _idlePrev: [Object], <br/> ontimeout: [Function] }, <br/> _idleStart: Thu, 15 Dec 2011 05:34:11 GMT, <br/> _events: { timeout: [Function], error: [Function], close: [Function] }, <br/> ondata: [Function], <br/> onend: [Function], <br/> _httpMessage: null }, <br/> httpVersionMajor: 1, <br/> httpVersionMinor: 1, <br/> upgrade: false }

file.size Content-length !== gzip Content-length 会出现一直loading,但图片已经传完了。

楼主你的Range不规范 <br/> <br/>请求下载整个文件: <br/>GET /test.rar HTTP/1.1 <br/>Connection: close <br/>Host: 116.1.219.219 <br/>Range: bytes=0-801 //一般请求下载整个文件是bytes=0- 或不用这个头 <br/> <br/>一般正常回应 <br/>HTTP/1.1 200 OK <br/>Content-Length: 801 <br/>Content-Type: application/octet-stream <br/>Content-Range: bytes 0-800/801 //801:文件总大小 <br/> <br/>你的测试是 Range: 0-20 <br/>少了 bytes=

我也遇到这问题 但把<meta charset=‘utf-8’ />后面的/去掉就不会了= =

文件服务器的思路还是比较通用的 :)

这个是个bug~

我正好需要写一个文件上传下载服务器给C客户端使用,可以好好学习下这篇文章了。呵呵。

刚接触nodejs不久,见到这篇文章 眼前顿时亮了

感觉很不错啊,

如果那这个作为企业网站的未见服务器,大家觉得如何?

@Jackson 这个bug有解决方法吗?

深入浅出啊(虽然我不懂这个词到底是个什么意思)。 如果这样的文章更多点的话,Nodejs的发展就不成问题了,小的作为一个菜鸟竟然也能体验到亲手打造静态服务器的过程。

if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) 这句话也写错了吧,难道就必须相等才行吗,判断条件应该是request.headers[ifModifiedSince])晚于lastModified 吧

###启发很大

这个是用于判断文件是否有变化…有变化,不管是晚于还是早于都要改变(早于和晚于取决于服务器时钟)

还好提醒了一下!!!作者怎么一直没改啊!

讲的太细致了,需要细细的品味!~

感谢分享,很受用!

This type of response MUST NOT have a body. Ignoring write() calls

使用Node.js做静态文件服务器,其性能如何?

没有nginx好

楼主,这个服务器貌似还是存在些bug。 例如访问根目录的话 http://localhost:8888/ 会导致程序崩溃

又例如:在asset目录下,再创建一个img目录,如果img目录为空,访问 http://localhost:8888/img/ 的话,也会导致程序崩溃。

应该对目录再添加判断。

最后非常感谢你的教程,为nodejs的新手带来了很好的入门资料。

好东东,学习,本来我也想写一个,看来省了

比深入浅出看着更有味儿

简直写的太好了 , express那些非要用路由才行

通俗易懂的好文

没有人会这么做

var row = fs.createReaderStream(filepath) row.pipe(response) 怎么页面什么都不显示啊

回到顶部