对于Node.js新手,搭建一个静态资源服务器是个不错的锻炼,从最简单的返回文件或错误开始,渐进增强,还可以逐步加深对http的理解。

基本功能

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

  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

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

路由处理

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

    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',会收到一堆乱码。

虽然有现成的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);
}

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

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的文件,却存在同名目录,因此第一次会收到重定向响应,并发起一个对目录的新请求。

缓存支持

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

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

    • 如服务器支持ETag,设置ETag
    • 如服务器支持Last-Modified,设置Last-Modified
    • 设置Expires
    • 设置Cache-Control头(设置其max-age值)

    浏览器收到响应后会存下这些标记,并在下次请求时带上与ETag对应的请求首部If-None-Match或与Last-Modified对应的请求首部If-Modified-Since

  2. 如果是重复的请求:
    • 浏览器判断缓存是否过期(通过Cache-ControlExpires确定)

      • 如果未过期,直接使用缓存内容,也就是强缓存命中,并不会产生新的请求
      • 如果已过期,会发起新的请求,并且请求会带上If-None-MatchIf-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应该大写),所以平常在浏览器中会看到大写或小写的首部字段。

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

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

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

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

内容编码

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

  1. 浏览器在访问网站时,默认会携带Accept-Encoding
  2. 服务器在收到请求后,如果发现存在Accept-Encoding请求头,并且支持该文件类型的压缩,压缩响应的实体主体(并不压缩头部),并附上Content-Encoding首部
  3. 浏览器收到响应,如果发现有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头。

范围请求

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

  1. 客户端向服务端发起请求
  2. 服务端响应,附上Accept-Ranges头(值表示表示范围的单位,通常是“bytes”),告诉客户端其接受范围请求
  3. 客户端发送新的请求,附上Ranges头,告诉服务端请求的是一个范围
  4. 服务端收到范围请求,分情况响应:
    • 范围有效,服务端返回206 Partial Content,发送指定范围内内容,并在Content-Range头中指定该范围
    • 范围无效,服务端返回416 Requested Range Not Satisfiable,并在Content-Range中指明可接受范围

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

Ranges: bytes=[start]-[end]

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

  1. 如果 end 省略,服务器应返回从 start 位置开始之后的所有字节
  2. 如果 start 省略,end 值指的就是服务器该返回最后多少个字节
  3. 如果均未省略,则服务器返回 start 和 end 之间的字节

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

  1. 当范围有效返回 206 时:

    Content-Range: bytes (start)-(end)/(total)
  2. 当范围无效返回 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:

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

读取命令行参数

至此,已经完成了静态服务器的基本功能。但是每一次需要修改配置,都必须修改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 app.js -p 8888 -i main.html

Note:

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

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

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

 

node 搭建静态服务的更多相关文章

  1. 用node搭建本地服务环境

    const express = require('express'); const path = require('path'); const request = require('request') ...

  2. 用node搭建静态文件服务器

    占个坑,写个node静态文件服务器

  3. 基于node 搭建http2服务

    1.准备工作:安装node2.安装http2: npm install http2 -g安装完成后,在安装目录中appData/Roaming>npm>node_modules>ht ...

  4. 使用Node搭建reactSSR服务端渲染架构

    如题:本文所讲架构主要用到技术栈有:Node, Express, React, Mobx, webpack4, ES6, ES7, axios, ejs,  log4js, scss,echarts, ...

  5. 使用node搭建静态资源服务器

    安装 npm install yumu-static-server -g 使用 shift+鼠标右键  在此处打开Powershell 窗口 server # 会在当前目录下启动一个静态资源服务器,默 ...

  6. live-server 快速搭建静态服务

    作为切图仔呢,平时都是在修改页面样式.展示后台返回的数据.调试js效果,这时候呢就需要搭一个服务器以便我们调试.平时我一般用的都是wamp,在www目录下新建项目然后开始撸代码,不过有时候呢不太方便, ...

  7. 使用Node.js搭建静态资源服务器

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

  8. 使用 Node.js 搭建微服务网关

    目录 Node.js 是什么 安装 node.js Node.js 入门 Node.js 应用场景 npm 镜像 使用 Node.js 搭建微服务网关 什么是微服务架构 使用 Node.js 实现反向 ...

  9. HTTPS静态服务搭建过程详解

    HTTPS服务对于一个前端开发者来说是一个天天打招呼的老伙计了,但是之前我跟HTTPS打交道的场景一直是抓包,自己没有亲自搭建过HTTPS服务,对HTTPS的底层知识也是一知半解.最近正好遇到一个用户 ...

随机推荐

  1. 如何做好错误处理?(PHP篇)

    起因 之前我在封装 PHP 一个类库的时候,如果有遇到错误(例如构造函数传参不合法的话),则直接 die() ,后来发现这种方法很不好,会直接退出程序. 所以我想到给 PHP 上异常捕获的机制了. 错 ...

  2. 使用Express构建RESTful API

    RESTful服务 REST(Representational State Transfer)的意思是表征状态转移,它是一种基于HTTP协议的网络应用接口风格,充分利用HTTP的方法实现统一风格接口的 ...

  3. Web API 2 对于 Content-Length 要求严格

    最近在做一个工具,里面有一个发起http请求的操作,虽然工具不是用.NET写的,但是测试用服务器软件是.NET写的.在这里选择了ASP.NET MVC和Web API 2. 首先预定义Student与 ...

  4. 课程二(Improving Deep Neural Networks: Hyperparameter tuning, Regularization and Optimization),第三周(Hyperparameter tuning, Batch Normalization and Programming Frameworks) —— 2.Programming assignments

    Tensorflow Welcome to the Tensorflow Tutorial! In this notebook you will learn all the basics of Ten ...

  5. c++中堆、栈、自由存储区和常量存储区(转)

    代码段 --text(code segment/text segment)text段在内存中被映射为只读,但.data和.bss是可写的.text段是程序代码段,在AT91库中是表示程序段的大小,它是 ...

  6. 查看MySQL 表结构

    前言:最近在实习中,做到跟MySQL相关的开发时,想起了好久前的一个笔试题——查看数据库表结构有哪几种方法: (一)使用DESCRIBE语句 DESCRIBE table_name; 或DESC ta ...

  7. IE中透明度的读写

    一.获取透明度 ele.filters.alpha 返回元素所有滤镜的对象,可在此基础上获取opacity即可. 但是似乎ele.filters只能存储第一个滤镜,而当我们把alpha放在第二位时,就 ...

  8. php -- 日期时间

    ----- 017-datetime.php ----- <!DOCTYPE html> <html> <head> <meta http-equiv=&qu ...

  9. Jenkins持久化集成使用

    1.概述 Jenkins是基于Java开发的一种持续集成工具,用于监控持续重复的工作,功能包括: 持续的软件版本发布/测试项目 监控外部调用执行的工作 2.搭建 2.1环境准备 首先我们要准备搭建的环 ...

  10. SpringBoot入门 (十一) 数据校验

    本文记录学习在SpringBoot中做数据校验. 一 什么是数据校验 数据校验就是在应用程序中,对输入进来得数据做语义分析判断,阻挡不符合规则得数据,放行符合规则得数据,以确保被保存得数据符合我们得数 ...