使用Node.js搭建静态资源服务详细教程


Posted in Javascript onAugust 02, 2017

对于Node.js新手,搭建一个静态资源服务器是个不错的锻炼,从最简单的返回文件或错误开始,渐进增强,还可以逐步加深对http的理解。那就开始吧,让我们的双手沾满网络请求!

Note:

当然在项目中如果有使用express框架,用express.static一行代码就可以达到目的了:

app.use(express.static('public'))

这里我们要实现的正是express.static背后所做工作的一部分,建议同步阅读该模块源码。

基本功能

不急着写下第一行代码,而是先梳理一下就基本功能而言有哪些步骤。

  1. 在本地根据指定端口启动一个http server,等待着来自客户端的请求
  2. 当请求抵达时,根据请求的url,以设置的静态文件目录为base,映射得到文件位置
  3. 检查文件是否存在
  4. 如果文件不存在,返回404状态码,发送not found页面到客户端
  5. 如果文件存在:
  • 打开文件待读取
  • 设置response header
  • 发送文件到客户端

6.等待来自客户端的下一个请求

实现基本功能

代码结构

创建一个nodejs-static-webserver目录,在目录内运行npm init初始化一个package.json文件。

mkdir nodejs-static-webserver && cd "$_"
// initialize package.json
npm init

接着创建如下文件目录:

-- config
---- default.json
-- static-server.js
-- app.js
default.json
{
 "port": 9527,
 "root": "/Users/sheila1227/Public",
 "indexPage": "index.html"
}

default.js存放一些默认配置,比如端口号、静态文件目录(root)、默认页(indexPage)等。当这样的一个请求http://localhost:9527/myfiles/抵达时. 如果根据root映射后得到的目录内有index.html,根据我们的默认配置,就会给客户端发回index.html的内容。

static-server.js

const http = require('http');
const path = require('path');
const config = require('./config/default');
class StaticServer {
 constructor() {
  this.port = config.port;
  this.root = config.root;
  this.indexPage = config.indexPage;
 }

 start() {
  http.createServer((req, res) => {
   const pathName = path.join(this.root, path.normalize(req.url));
   res.writeHead(200);
   res.end(`Requeste path: ${pathName}`);
  }).listen(this.port, err => {
   if (err) {
    console.error(err);
    console.info('Failed to start server');
   } else {
    console.info(`Server started on port ${this.port}`);
   }
  });
 }
}
module.exports = StaticServer;

在这个模块文件内,我们声明了一个StaticServer类,并给其定义了start方法,在该方法体内,创建了一个server对象,监听rquest事件,并将服务器绑定到配置文件指定的端口。在这个阶段,我们对于任何请求都暂时不作区分地简单地返回请求的文件路径。path模块用来规范化连接和解析路径,这样我们就不用特意来处理操作系统间的差异。

app.js

const StaticServer = require('./static-server');
(new StaticServer()).start();

在这个文件内,调用上面的static-server模块,并创建一个StaticServer实例,调用其start方法,启动了一个静态资源服务器。这个文件后面将不需要做其他修改,所有对静态资源服务器的完善都发生在static-server.js内。

在目录下启动程序会看到成功启动的log:

> node app.js
Server started on port 9527

在浏览器中访问,可以看到服务器将请求路径直接返回了。

使用Node.js搭建静态资源服务详细教程

路由处理

之前我们对任何请求都只是向客户端返回文件位置而已,现在我们将其替换成返回真正的文件:

routeHandler(pathName, req, res) {  
 }
 start() {
  http.createServer((req, res) => {
   const pathName = path.join(this.root, path.normalize(req.url));
   this.routeHandler(pathName, req, res);
  }).listen(this.port, err => {
   ...
  });
 }

将由routeHandler来处理文件发送。

读取静态文件

读取文件之前,用fs.stat检测文件是否存在,如果文件不存在,回调函数会接收到错误,发送404响应.

respondNotFound(req, res) {
  res.writeHead(404, {
   'Content-Type': 'text/html'
  });
  res.end(`<h1>Not Found</h1><p>The requested URL ${req.url} was not found on this server.</p>`);
 }

 respondFile(pathName, req, res) {
  const readStream = fs.createReadStream(pathName);
  readStream.pipe(res);
 }

 routeHandler(pathName, req, res) {
  fs.stat(pathName, (err, stat) => {
   if (!err) {
    this.respondFile(pathName, req, res);
   } else {
    this.respondNotFound(req, res);
   }
  });
 }

Note:

读取文件,这里用的是流的形式createReadStream而不是readFile,是因为后者会在得到完整文件内容之前将其先读到内存里。这样万一文件很大,再遇上多个请求同时访问,readFile就承受不来了。使用文件可读流,服务端不用等到数据完全加载到内存再发回给客户端,而是一边读一边发送分块响应。这时响应里会包含如下响应头:

Transfer-Encoding:chunked

默认情况下,可读流结束时,可写流的end()方法会被调用。

MIME支持

现在给客户端返回文件时,我们并没有指定Content-Type头,虽然你可能发现访问文本或图片浏览器都可以正确显示出文字或图片,但这并不符合规范。任何包含实体主体(entity body)的响应都应在头部指明文件类型,否则浏览器无从得知类型时,就会自行猜测(从文件内容以及url中寻找可能的扩展名)。响应如指定了错误的类型也会导致内容的错乱显示,如明明返回的是一张jpeg图片,却错误指定了header:'Content-Type': 'text/html',会收到一堆乱码。

使用Node.js搭建静态资源服务详细教程

虽然有现成的mime模块可用,这里还是自己来实现吧,试图对这个过程有更清晰的理解。

在根目录下创建mime.js文件:

const path = require('path');
const mimeTypes = {
 "css": "text/css",
 "gif": "image/gif",
 "html": "text/html",
 "ico": "image/x-icon",
 "jpeg": "image/jpeg",
  ...
};
const lookup = (pathName) => {
 let ext = path.extname(pathName);
 ext = ext.split('.').pop();
 return mimeTypes[ext] || mimeTypes['txt'];
}
module.exports = {
 lookup
};

该模块暴露出一个lookup方法,可以根据路径名返回正确的类型,类型以‘type/subtype'表示。对于未知的类型,按普通文本处理。

接着在static-server.js中引入上面的mime模块,给返回文件的响应都加上正确的头部字段:

respondFile(pathName, req, res) {
  const readStream = fs.createReadStream(pathName);
  res.setHeader('Content-Type', mime.lookup(pathName));
  readStream.pipe(res);
 }

重新运行程序,会看到图片可以在浏览器中正常显示了。

使用Node.js搭建静态资源服务详细教程

Note:

需要注意的是,Content-Type说明的应是原始实体主体的文件类型。即使实体经过内容编码(如gzip,后面会提到),该字段说明的仍应是编码前的实体主体的类型。

添加其他功能

至此,已经完成了基本功能中列出的几个步骤,但依然有很多需要改进的地方,比如如果用户输入的url对应的是磁盘上的一个目录怎么办?还有,现在对于同一个文件(从未更改过)的多次请求,服务端都是勤勤恳恳地一遍遍地发送回同样的文件,这些冗余的数据传输,既消耗了带宽,也给服务器添加了负担。另外,服务器如果在发送内容之前能对其进行压缩,也有助于减少传输时间。

读取文件目录

现阶段,用url: localhost:9527/testfolder去访问一个指定root文件夹下真实存在的testfolder的文件夹,服务端会报错:

Error: EISDIR: illegal operation on a directory, read

要增添对目录访问的支持,我们重新整理下响应的步骤:

1.请求抵达时,首先判断url是否有尾部斜杠

2.如果有尾部斜杠,认为用户请求的是目录

  • 如果目录存在
  • 如果目录下存在默认页(如index.html),发送默认页
  • 如果不存在默认页,发送目录下内容列表
  • 如果目录不存在,返回404

3.如果没有尾部斜杠,认为用户请求的是文件

  • 如果文件存在,发送文件
  • 如果文件不存在,判断同名的目录是否存在
  • 如果存在该目录,返回301,并在原url上添加上/作为要转到的location
  • 如果不存在该目录,返回404

我们需要重写一下routeHandler内的逻辑:

routeHandler(pathName, req, res) {
  fs.stat(pathName, (err, stat) => {
   if (!err) {
    const requestedPath = url.parse(req.url).pathname;
    if (hasTrailingSlash(requestedPath) && stat.isDirectory()) {
     this.respondDirectory(pathName, req, res);
    } else if (stat.isDirectory()) {
     this.respondRedirect(req, res);
    } else {
     this.respondFile(pathName, req, res);
    }
   } else {
    this.respondNotFound(req, res);
   }
  });
 }

继续补充respondRedirect方法:

respondRedirect(req, res) {
  const location = req.url + '/';
  res.writeHead(301, {
   'Location': location,
   'Content-Type': 'text/html'
  });
  res.end(`Redirecting to <a href='${location}'>${location}</a>`);
 }

浏览器收到301响应时,会根据头部指定的location字段值,向服务器发出一个新的请求。

继续补充respondDirectory方法:

respondDirectory(pathName, req, res) {
  const indexPagePath = path.join(pathName, this.indexPage);
  if (fs.existsSync(indexPagePath)) {
   this.respondFile(indexPagePath, req, res);
  } else {
   fs.readdir(pathName, (err, files) => {
    if (err) {
     res.writeHead(500);
     return res.end(err);
    }
    const requestPath = url.parse(req.url).pathname;
    let content = `<h1>Index of ${requestPath}</h1>`;
    files.forEach(file => {
     let itemLink = path.join(requestPath,file);
     const stat = fs.statSync(path.join(pathName, file));
     if (stat && stat.isDirectory()) {
      itemLink = path.join(itemLink, '/');
     }     
     content += `<p><a href='${itemLink}'>${file}</a></p>`;
    });
    res.writeHead(200, {
     'Content-Type': 'text/html'
    });
    res.end(content);
   });
  }
 }

当需要返回目录列表时,遍历所有内容,并为每项创建一个link,作为返回文档的一部分。需要注意的是,对于子目录的href,额外添加一个尾部斜杠,这样可以避免访问子目录时的又一次重定向。

在浏览器中测试一下,输入localhost:9527/testfolder,指定的root目录下并没有名为testfolder的文件,却存在同名目录,因此第一次会收到重定向响应,并发起一个对目录的新请求。

使用Node.js搭建静态资源服务详细教程

缓存支持

为了减少数据传输,减少请求数,继续添加缓存支持。首先梳理一下缓存的处理流程:

1.如果是第一次访问,请求报文首部不会包含相关字段,服务端在发送文件前做如下处理:

  • 如服务器支持ETag,设置ETag头
  • 如服务器支持Last-Modified,设置Last-Modified头
  • 设置Expires头
  • 设置Cache-Control头(设置其max-age值)
  • 浏览器收到响应后会存下这些标记,并在下次请求时带上与ETag对应的请求首部If-None-Match或与Last-Modified对应的请求首部If-Modified-Since。

2.如果是重复的请求:

浏览器判断缓存是否过期(通过Cache-Control和Expires确定)

如果未过期,直接使用缓存内容,也就是强缓存命中,并不会产生新的请求

如果已过期,会发起新的请求,并且请求会带上If-None-Match或If-Modified-Since,或者兼具两者

服务器收到请求,进行缓存的新鲜度再验证:
首先检查请求是否有If-None-Match首部,没有则继续下一步,有则将其值与文档的最新ETag匹配,失败则认为缓存不新鲜,成功则继续下一步

接着检查请求是否有If-Modified-Since首部,没有则保留上一步验证结果,有则将其值与文档最新修改时间比较验证,失败则认为缓存不新鲜,成功则认为缓存新鲜

当两个首部皆不存在或者验证结果是不新鲜时,发送200及最新文件,并在首部更新新鲜度。

当验证结果是缓存仍然新鲜时(也就是弱缓存命中),不需发送文件,仅发送304,并在首部更新新鲜度

为了能启用或关闭某种验证机制,我们在配置文件里增添如下配置项:

default.json:

{
 ...
 "cacheControl": true,
 "expires": true,
 "etag": true,
 "lastModified": true,
 "maxAge": 5
}

这里为了能测试到缓存过期,将过期时间设成了非常小的5秒。

在StaticServer类中接收这些配置:

class StaticServer {
 constructor() {
   ...
  this.enableCacheControl = config.cacheControl;
  this.enableExpires = config.expires;
  this.enableETag = config.etag;
  this.enableLastModified = config.lastModified;
  this.maxAge = config.maxAge;
 }

现在,我们要在原来的respondFile前横加一杠,增加是要返回304还是200的逻辑。

respond(pathName, req, res) {
  fs.stat(pathName, (err, stat) => {
   if (err) return respondError(err, res);
   this.setFreshHeaders(stat, res);
   if (this.isFresh(req.headers, res._headers)) {
    this.responseNotModified(res);
   } else {
    this.responseFile(pathName, res);
   }
  });
 }

准备返回文件前,根据配置,添加缓存相关的响应首部。

generateETag(stat) {
  const mtime = stat.mtime.getTime().toString(16);
  const size = stat.size.toString(16);
  return `W/"${size}-${mtime}"`;
 }
 setFreshHeaders(stat, res) {
  const lastModified = stat.mtime.toUTCString();
  if (this.enableExpires) {
   const expireTime = (new Date(Date.now() + this.maxAge * 1000)).toUTCString();
   res.setHeader('Expires', expireTime);
  }
  if (this.enableCacheControl) {
   res.setHeader('Cache-Control', `public, max-age=${this.maxAge}`);
  }
  if (this.enableLastModified) {
   res.setHeader('Last-Modified', lastModified);
  }
  if (this.enableETag) {
   res.setHeader('ETag', this.generateETag(stat));
  }
 }

需要注意的是,上面使用了ETag弱验证器,并不能保证缓存文件与服务器上的文件是完全一样的。关于强验证器如何实现,可以参考etag包的源码。

下面是如何判断缓存是否仍然新鲜:

isFresh(reqHeaders, resHeaders) {
  const noneMatch = reqHeaders['if-none-match'];
  const lastModified = reqHeaders['if-modified-since'];
  if (!(noneMatch || lastModified)) return false;
  if(noneMatch && (noneMatch !== resHeaders['etag'])) return false;
  if(lastModified && lastModified !== resHeaders['last-modified']) return false;
  return true;
 }

需要注意的是,http首部字段名是不区分大小写的(但http method应该大写),所以平常在浏览器中会看到大写或小写的首部字段。

使用Node.js搭建静态资源服务详细教程

使用Node.js搭建静态资源服务详细教程

但是node的http模块将首部字段都转成了小写,这样在代码中使用起来更方便些。所以访问header要用小写,如reqHeaders['if-none-match']。不过,仍然可以用req.rawreq.rawHeaders来访问原headers,它是一个[name1, value1, name2, value2, ...]形式的数组。

现在来测试一下,因为设置的缓存有效时间是极小的5s,所以强缓存几乎不会命中,所以第二次访问文件会发出新的请求,因为服务端文件并没做什么改变,所以会返回304。

使用Node.js搭建静态资源服务详细教程

现在来修改一下请求的这张图片,比如修改一下size,目的是让服务端的再验证失败,因而必须给客户端发送200和最新的文件。

使用Node.js搭建静态资源服务详细教程

接下来把缓存有效时间改大一些,比如10分钟,那么在10分钟之内的重复请求,都会命中强缓存,浏览器不会向服务端发起新的请求(但network依然能观察到这条请求)。

使用Node.js搭建静态资源服务详细教程

内容编码

服务器在发送很大的文档之前,对其进行压缩,可以节省传输用时。其过程是:

浏览器在访问网站时,默认会携带Accept-Encoding头

服务器在收到请求后,如果发现存在Accept-Encoding请求头,并且支持该文件类型的压缩,压缩响应的实体主体(并不压缩头部),并附上Content-Encoding首部

浏览器收到响应,如果发现有Content-Encoding首部,按其值指定的格式解压报文

对于图片这类已经经过高度压缩的文件,无需再额外压缩。因此,我们需要配置一个字段,指明需要针对哪些类型的文件进行压缩。

default.json

{
 ...
 "zipMatch": "^\\.(css|js|html)$"
}
static-server.js
 constructor() {
  ...
  this.zipMatch = new RegExp(config.zipMatch);
 }

用zlib模块来实现流压缩:

compressHandler(readStream, req, res) { const acceptEncoding = req.headers['accept-encoding']; if (!acceptEncoding || !acceptEncoding.match(/\b(gzip|deflate)\b/)) { return readStream; } else if (acceptEncoding.match(/\bgzip\b/)) { res.setHeader('Content-Encoding', 'gzip'); return readStream.pipe(zlib.createGzip()); } else if (acceptEncoding.match(/\bdeflate\b/)) { res.setHeader('Content-Encoding', 'deflate'); return readStream.pipe(zlib.createDeflate()); } }

因为配置了图片不需压缩,在浏览器中测试会发现图片请求的响应中没有Content-Encoding头。

范围请求

最后一步,使服务器支持范围请求,允许客户端只请求文档的一部分。其流程是:

  • 客户端向服务端发起请求
  • 服务端响应,附上Accept-Ranges头(值表示表示范围的单位,通常是“bytes”),告诉客户端其接受范围请求
  • 客户端发送新的请求,附上Ranges头,告诉服务端请求的是一个范围
  • 服务端收到范围请求,分情况响应:

范围有效,服务端返回206 Partial Content,发送指定范围内内容,并在Content-Range头中指定该范围

范围无效,服务端返回416 Requested Range Not Satisfiable,并在Content-Range中指明可接受范围

请求中的Ranges头格式为(这里不考虑多范围请求了):

Ranges: bytes=[start]-[end]

其中 start 和 end 并不是必须同时具有:

如果 end 省略,服务器应返回从 start 位置开始之后的所有字节

如果 start 省略,end 值指的就是服务器该返回最后多少个字节

如果均未省略,则服务器返回 start 和 end 之间的字节

响应中的Content-Range头有两种格式:

当范围有效返回 206 时:

Content-Range: bytes (start)-(end)/(total)

当范围无效返回 416 时:

Content-Range: bytes */(total)

添加函数处理范围请求:

rangeHandler(pathName, rangeText, totalSize, res) {
  const range = this.getRange(rangeText, totalSize);
  if (range.start > totalSize || range.end > totalSize || range.start > range.end) {
   res.statusCode = 416;
   res.setHeader('Content-Range', `bytes */${totalSize}`);
   res.end();
   return null;
  } else {
   res.statusCode = 206;
   res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${totalSize}`);
   return fs.createReadStream(pathName, { start: range.start, end: range.end });
  }
 }

用 Postman来测试一下。在指定的root文件夹下创建一个测试文件:

testfile.js

This is a test sentence.

请求返回前六个字节 ”This “ 返回 206:

使用Node.js搭建静态资源服务详细教程

请求一个无效范围返回416:

使用Node.js搭建静态资源服务详细教程

读取命令行参数

至此,已经完成了静态服务器的基本功能。但是每一次需要修改配置,都必须修改default.json文件,非常不方便,如果能接受命令行参数就好了,可以借助 yargs 模块来完成。

var options = require( "yargs" )
 .option( "p", { alias: "port", describe: "Port number", type: "number" } )
 .option( "r", { alias: "root", describe: "Static resource directory", type: "string" } )
 .option( "i", { alias: "index", describe: "Default page", type: "string" } )
 .option( "c", { alias: "cachecontrol", default: true, describe: "Use Cache-Control", type: "boolean" } )
 .option( "e", { alias: "expires", default: true, describe: "Use Expires", type: "boolean" } )
 .option( "t", { alias: "etag", default: true, describe: "Use ETag", type: "boolean" } )
 .option( "l", { alias: "lastmodified", default: true, describe: "Use Last-Modified", type: "boolean" } )
 .option( "m", { alias: "maxage", describe: "Time a file should be cached for", type: "number" } )
 .help()
 .alias( "?", "help" )
 .argv;

瞅瞅 help 命令会输出啥:

使用Node.js搭建静态资源服务详细教程

这样就可以在命令行传递端口、默认页等:

node app.js -p 8888 -i main.html

总结

以上所述是小编给大家介绍的使用Node.js搭建静态资源服务详细教程,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对三水点靠木网站的支持!

Javascript 相关文章推荐
jquery 入门教程 [翻译] 推荐
Aug 17 Javascript
jQuery Migrate 1.1.0 Released 注意事项
Jun 14 Javascript
JavaScript 实现打印,打印预览,打印设置
Dec 30 Javascript
vue实现可增删查改的成绩单
Oct 27 Javascript
JS根据生日月份和日期计算星座的简单实现方法
Nov 24 Javascript
jQuery复合事件用法示例
Jun 10 jQuery
jQuery上传插件webupload使用方法
Aug 01 jQuery
jQuery EasyUI 折叠面板accordion的使用实例(分享)
Dec 25 jQuery
JS中touchstart事件与click事件冲突的解决方法
Mar 12 Javascript
微信小程序动态生成二维码的实现代码
Jul 25 Javascript
微信小程序自定义tabBar在uni-app的适配详解
Sep 30 Javascript
纯 JS 实现放大缩小拖拽功能(完整代码)
Nov 25 Javascript
原生js获取left值和top值的三种方法
Aug 02 #Javascript
认识less和webstrom的less配置方法
Aug 02 #Javascript
基于Jquery Ajax type的4种类型(详解)
Aug 02 #jQuery
详解基于node的前端项目编译时内存溢出问题
Aug 01 #Javascript
javascript 中select框触发事件过程的分析
Aug 01 #Javascript
谈谈对vue响应式数据更新的误解
Aug 01 #Javascript
详解jquery选择器的原理
Aug 01 #jQuery
You might like
php后台程序与Javascript的两种交互方式
2009/10/25 PHP
基于CakePHP实现的简单博客系统实例
2015/06/28 PHP
PHP实现用session来实现记录用户登陆信息
2018/10/15 PHP
让Firefox支持event对象实现代码
2009/11/07 Javascript
浅析jquery的作用与优势
2013/12/02 Javascript
javascript的propertyIsEnumerable()方法使用介绍
2014/04/09 Javascript
js获取上传文件大小示例代码
2014/04/10 Javascript
深入分析jquery解析json数据
2014/12/09 Javascript
nodejs批量修改文件编码格式
2015/01/22 NodeJs
JavaScript学习笔记之内置对象
2015/01/22 Javascript
jQuery实现的仿select功能代码
2015/08/19 Javascript
js实现随屏幕滚动的带缓冲效果的右下角广告代码
2015/09/04 Javascript
JS实现先显示大图后自动收起显示小图的广告代码
2015/09/04 Javascript
jquery.validate提示错误信息位置方法
2016/01/22 Javascript
JavaScript的for循环中嵌套一个点击事件的问题解决
2017/03/03 Javascript
正则表达式基本语法及表单验证操作详解【基于JS】
2017/04/07 Javascript
JavaScript之RegExp_动力节点Java学院整理
2017/06/29 Javascript
基于AngularJS的拖拽文件上传的实例代码
2017/07/15 Javascript
javascript 产生随机数的几种方法总结
2017/09/26 Javascript
Vue官方文档梳理之全局配置
2017/11/22 Javascript
jQuery使用动画队列自定义动画操作示例
2018/06/16 jQuery
一份超级详细的Vue-cli3.0使用教程【推荐】
2018/11/15 Javascript
详解Nuxt.js中使用Element-UI填坑
2019/09/06 Javascript
Python入门篇之文件
2014/10/20 Python
在Python中的Django框架中进行字符串翻译
2015/07/27 Python
Python中getpass模块无回显输入源码解析
2018/01/11 Python
Python守护进程实现过程详解
2020/02/10 Python
基于Tensorflow一维卷积用法详解
2020/05/22 Python
浅谈如何使用python抓取网页中的动态数据实现
2020/08/17 Python
欧洲领先的火车票和大巴票预订平台:Trainline
2018/12/26 全球购物
应届生高等护理求职信
2013/10/12 职场文书
中医临床专业自我鉴定范文
2014/01/15 职场文书
烛光里的微笑观后感
2015/06/17 职场文书
2016消防宣传标语口号
2015/12/26 职场文书
Java移除无效括号的方法实现
2021/08/07 Java/Android
Python 使用 Frame tkraise() 方法在 Tkinter 应用程序中的Frame之间切换
2022/04/24 Python