在第一次迭代之后,我们已经有了一个可工作的版本,满足了功能需求。接下来我们需要从性能的角度出发,看看代码还有哪些改进余地。
把map
方法换成for
循环或许会更快一些,但第一版代码最大的性能问题存在于从读取文件到输出响应的过程当中。我们以处理/??a.js,b.js,c.js
这个请求为例,看看整个处理过程中耗时在哪儿。
发送请求 等待服务端响应 接收响应 ---------+----------------------+-------------> -- 解析请求 ------ 读取a.js ------ 读取b.js ------ 读取c.js -- 合并数据 -- 输出响应
可以看到,第一版代码依次把请求的文件读取到内存中之后,再合并数据和输出响应。这会导致以下两个问题:
当请求的文件比较多比较大时,串行读取文件会比较耗时,从而拉长了服务端响应等待时间。
由于每次响应输出的数据都需要先完整地缓存在内存里,当服务器请求并发数较大时,会有较大的内存开销。
对于第一个问题,很容易想到把读取文件的方式从串行改为并行。但是别这样做,因为对于机械磁盘而言,因为只有一个磁头,尝试并行读取文件只会造成磁头频繁抖动,反而降低IO效率。而对于固态硬盘,虽然的确存在多个并行IO通道,但是对于服务器并行处理的多个请求而言,硬盘已经在做并行IO了,对单个请求采用并行IO无异于拆东墙补西墙。因此,正确的做法不是改用并行IO,而是一边读取文件一边输出响应,把响应输出时机提前至读取第一个文件的时刻。这样调整后,整个请求处理过程变成下边这样。
发送请求 等待服务端响应 接收响应 ---------+----+-------------------------------> -- 解析请求 -- 检查文件是否存在 -- 输出响应头 ------ 读取和输出a.js ------ 读取和输出b.js ------ 读取和输出c.js
按上述方式解决第一个问题后,因为服务器不需要完整地缓存每个请求的输出数据了,第二个问题也迎刃而解。
根据以上设计,第二版代码按以下方式调整了部分函数。
function main(argv) { var config = JSON.parse(fs.readFileSync(argv[0], 'utf-8')), root = config.root || '.', port = config.port || 80; http.createServer(function (request, response) { var urlInfo = parseURL(root, request.url); validateFiles(urlInfo.pathnames, function (err, pathnames) { if (err) { response.writeHead(404); response.end(err.message); } else { response.writeHead(200, { 'Content-Type': urlInfo.mime }); outputFiles(pathnames, response); } }); }).listen(port); } function outputFiles(pathnames, writer) { (function next(i, len) { if (i < len) { var reader = fs.createReadStream(pathnames[i]); reader.pipe(writer, { end: false }); reader.on('end', function() { next(i + 1, len); }); } else { writer.end(); } }(0, pathnames.length)); } function validateFiles(pathnames, callback) { (function next(i, len) { if (i < len) { fs.stat(pathnames[i], function (err, stats) { if (err) { callback(err); } else if (!stats.isFile()) { callback(new Error()); } else { next(i + 1, len); } }); } else { callback(null, pathnames); } }(0, pathnames.length)); }
可以看到,第二版代码在检查了请求的所有文件是否有效之后,立即就输出了响应头,并接着一边按顺序读取文件一边输出响应内容。并且,在读取文件时,第二版代码直接使用了只读数据流来简化代码。