npm
里有个 http-server
的模块,是一个简单的、零配置的 HTTP 服务,它很是强大,同时很是简单,能够方便的帮助咱们开启本地服务器,以及局域网共享,能够用来作测试,开发,学习时的环境配置,咱们本节就模拟 http-server
实现一个本身的启动本地服务的命令行工具。css
http-server
服务器经过命令行启动,使用时须要安装,安装命令以下:html
npm install http-server -g前端
启动本地服务器时在根目录下执行下面命令便可:node
http-server [path] [option]npm
path
默认状况下是 ./public
,不然是 ./
,启动后能够经过 http://localhost:8080 来访问服务器,options
为其余参数, npm
官方文档 www.npmjs.com/package/htt… 有详细说明。json
当经过浏览器访问 http://localhost:8080 之后,会将咱们服务器根目录的目录结构显示在浏览器页面上,当点击文件夹时,能够继续显示内部的文件和文件夹,当点击文件时会直接经过服务器访问文件,并将文件内容显示在浏览器页面上。数组
chalk
模块是用来控制命令行输出的文字颜色的第三方模块,使用前须要安装,安装命令以下:浏览器
npm install chalk缓存
chalk
模块的用法以下,模块支持的颜色和更多的 API 能够在 npm
官方文档 www.npmjs.com/package/cha… 中查看。bash
const chalk = require("chalk");
// 在命令行打印绿色和红色的 hello
console.log(chalk.green("hello"));
console.log(chalk.red("hello"));复制代码
在命令行窗口输入 node chalk-test.js
查看命令行打印 hello
的颜色。
debug
模块能够匹配当前环境变量 DEBUG
的值并输出相关信息,做用在于命令行工具能够根据不一样状况输出的信息进行调试,是第三方模块,使用前需安装,命令以下。
npm install debug
debug
的简单使用以下,若是想了解更详细的 API 能够在 npm
官方文档 www.npmjs.com/package/deb… 中查看。
const debug = require("debug")("hello");
debug("hi panda");复制代码
当咱们在命令行中执行 node debug-test1.js
时发现命令窗口什么也没有打印,那是由于当前根目录的环境变量 DEBUG
的值必须和咱们设置的 hello
相匹配才会打印相关信息。
设置环境变量,Window 能够经过 set DEBUG=hello
设置,Mac 能够经过 export DEBUG=hello
设置,设置环境变量后再次执行 node debug-test.js
,咱们会发现命令行打印出了下面内容。
hello hi panda +0ms
其中 hello
为咱们设置 DEBUG
环境变量的值,hi panda
为调试方法 debug
方法打印的信息,+0ms
为距离上次执行的间隔时间。
const debugA = require("debug")("hello:a");
const debugB = require("debug")("hello:b");
debugA("hi panda");
debugB("hello panda");复制代码
上面的代码目的是可让咱们不一样的 debug
方法能够匹配不一样的环境变量,因此须要从新将环境变量的值设置为 hello:*
,这样再次执行 node debug-test2.js
发现命令窗口打印了以下内容。
hello:a hi panda +0ms
hello:b hello panda +0ms
使用 debug
的好处就是能够在开发的时候打印一些调试用的信息,在开发完成后由于匹配不到环境变量,这些信息就会被隐藏。
commander
是著名的 Node 大神 TJ
的 “做品”,是一个开发命令行工具的解决方案,提供了用户命令行输入和参数解析的强大功能,commander
是第三方模块,使用时须要安装,命令以下。
npm install commander
基本用法以下:
let commander = require("commander");
// 解析 Node 进程执行时的参数
commander.version("1.0.0").parse(process.argv);复制代码
上面文件中 version
方法表明当前执行文件模块的版本,parse
为解析参数为当前命令行进程参数的方法,process.argv
为参数集合(数组),第一个参数为执行的 node.exe
文件的绝对路径,第二个参数为当前执行文件的绝对路径,后面为经过命令行传入的参数,如 --host
、--port
等。
在命令行执行 node commander-test.js --help
时默认会在命令行输出以下信息:
Usage: [options]
Options:
-V, --version output the version number
-h, --help output usage information
固然在咱们的命令行工具中,参数不仅 --version
和 --help
两个,咱们更但愿更多的参数更多的功能,而且可定制的描述信息,使用案例以下。
let commander = require("commander");
// 解析 Node 进程执行时的参数
commander
.version("1.0.0")
.usage("[options]")
.option('-p, --port <n>', 'server port')
.option('-o, --host <n>', 'server host')
.option('-d, --dir <n>', 'server dir')
.parse(process.argv);
console.log(commander.port); // 3000
console.log(commander.host); // localhost
console.log(commander.dir); // public复制代码
在执行命令 node commander-test2.js --help
后会在命令窗口输出以下信息:
Usage: yourname-http-server [options]
Options:
-V, --version output the version number
-p, --port server port
-o, --host server host
-d, --dir server dir
-h, --help output usage information
usage
方法可让咱们详细的定制参数的类型和描述,option
方法可让咱们添加执行 --help
指令时打印的命令以及对应的描述信息。
执行下面命令:
node commander-test2.js --port 3000 --host localhost --dir public
执行命令后咱们发现其实给咱们的参数挂在了 commander
对象上,方便咱们取值。
在咱们使用别人的命令行工具时会发如今上面输出信息的时候常常会在下面输出 How to use
的列表,更详细的描述了每条命令的做用及用法。
let commander = require("commander");
// 必须写到 parse 方法的前面
commander.on("--help", function () {
console.log("\r\n How to use:")
console.log(" yourname-http-server --port <val>");
console.log(" yourname-http-server --host <val>");
console.log(" yourname-http-server --dir <val>");
});
// 解析 Node 进程执行时的参数
commander
.version("1.0.0")
.usage("[options]")
.option('-p, --port <n>', 'server port')
.option('-o, --host <n>', 'server host')
.option('-d, --dir <n>', 'server dir')
.parse(process.argv);复制代码
再次执行命令 node commander-test2.js --help
后会在命令窗口输出以下信息:
Usage: yourname-http-server [options]
Options:
-V, --version output the version number
-p, --port server port
-o, --host server host
-d, --dir server dir
-h, --help output usage information
How to use:
yourname-http-server --port
yourname-http-server --host
yourname-http-server --dir
以上是 commander
模块的基本用法,如想了解更详细的 API 和使用案例能够到 npm
官方文档查看,地址以下 www.npmjs.com/package/com… 。
static
|- bin
| |- yourname-http-server.js
|- public
| |- css
| | |- style.css
| |- index.html
| |- 1.txt
|- tests
| |- chalk-test.js
| |- commander-test1.js
| |- commander-test2.js
| |- commander-test3.js
| |- debug-test1.js
| |- debug-test2.js
|- config.js
|- index.html
|- index.js
|- package-lock.json
|- package.json复制代码
在启动静态服务的时候,咱们但愿能够经过命令行传参的形式来定义当前启动服务的主机名端口号,以及默认检索的文件根目录,因此须要配置文件来实现灵活传参。
module.exports = {
port: 3000,
host: "localhost",
dir: process.cwd()
}复制代码
在上面的配置中,默认端口号为 3000
,默认主机名为 localhost
,咱们设置默认检索文件的根目录为经过命令行启动服务器的目录,而 process.cwd()
的值就是咱们启动命令行执行命令的目录的绝对路径。
由于咱们的命令行工具启动本地服务多是在系统的任意位置,或者指定启动服务访问的域,提升可配置性,而且要更方便给服务器扩展更多的方法处理不一样的逻辑,因此须要建立一个 Server
类。
// 引入依赖
const http = require("http");
const url = require("url");
const path = require("path");
const fs = require("mz/fs");
const mime = require("mime");
const zlib = require("zlib");
const chalk = require("chalk");
const ejs = require("ejs");
const debug = require("debug")("http:a");
// 引入配置文件
const config = require("./config");
// 读取模板文件
const templateStr = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
class Server {
constructor() {
this.config = config; // 配置
this.template = templateStr; // 模板
}
}复制代码
咱们在上面代码中引入了 config.js
配置文件,读取了用于启动服务后展现页面 index.html
的内容,并都挂在了 Server
类的实例上,目的是方便内部的方法使用以及达到不轻易操做全局变量的目的。
后面为了方便代码的拆分,咱们将原型上的方法统一使用 Server.prototype.xxx
的方式来书写,实际的案例都是写在 Server
类里面的。
Server.prototype.start = function () {
// 建立服务
const server = http.createServer(this.handleRequest.bind(this));
// 从配置中解构端口号和主机名
let { port, host } = this.config;
// 启动服务
server.listen(port, host, () => {
debug(`server start http://${host}:${chalk.green(port)}`);
});
}复制代码
在 start
方法中建立了服务,在启动服务时只须要建立 Server
的实例并调用 start
方法,因为服务的回调中会处理不少请求响应的逻辑,会致使 start
方法的臃肿,因此将服务的回调函数抽取成 Server
类的一个实例方法 handleRequest
,须要注意的是 handleRequest
内部的 this
指向须要咱们修正。
在启动服务时咱们根据配置能够灵活的设置服务的地址,当设置 host
后,服务将只能经过 host
的值做为主机名的地址访问静态服务器,启动服务的提示咱们经过匹配环境变量 DEBUG
的 debug
方法来打印,并将端口号设置成绿色。
在实现 handleRequest
以前咱们应该了解要实现的功能,在 http-server
中,若是访问的服务地址路径后面指定具体要访问的文件,而且当前启动服务根目录按照访问路径能够查找到文件,将文件内容读取后响应给客户端,若是没指定文件,应该检索当前启动服务根目录或默认设置的目录结构,并将文件的结构经过模板渲染成超连接后将页面响应给客户端,再次点击页面的上的连接,若是是文件,直接读取并响应文件内容,若是是文件夹,则继续检索内部结构经过模板渲染成页面。
Server.prototype.handleRequest = async function (req, res) {
// 获取访问的路径,默认为 /
this.pathname = url.parse(req.url, true).pathname;
// 将访问的路径名转换成绝对路径,取到的 dir 就是绝对路径
this.realPath = path.join(this.config.dir, this.pathname);
debug(realPath); // 打印当前访问的绝对路径,用于调试
try {
// 获取 statObj 对象,若是 await 同步使用 try...catch 捕获非法路径
let statObj = await fs.stat(this.realPath);
if (statObj.isFile()) {
// 若是是文件,直接返回文件内容
this.sendFile(req, res, statObj);
} else {
// 若是是文件夹则检索文件夹经过模板渲染后返回页面
this.sendDirDetails(req, res, statObj);
}
} catch (e) {
// 若是路径非法,发送错误响应
this.sendError(req, res, e);
}
}复制代码
handleRequest
因为内部须要使用异步操做获取 statObj
对象,因此咱们使用了 async
函数,为了函数内部可使用 await
避免异步回调嵌套,因为 await
会等待到异步执行完毕后继续向下执行,咱们可使用 try...catch...
捕获非法的访问路径,并作出错误响应。
若是路径合法,咱们须要检测访问路径对应的是文件仍是文件夹,若是是文件则执行响应内容的逻辑,是文件夹执行检索文件夹渲染内部文件列表返回页面的逻辑。
因此咱们将错误处理逻辑、响应文件内容逻辑和返回文件夹详情页面的逻辑分别抽离成 Server
类的三个实例方法 sendError
、sendFile
和 sendDirDetails
,使得 handleRequest
方法逻辑清晰且不那么臃肿。
在服务器处理不一样的请求和响应时可能须要处理不一样的错误,这些错误的不一样就是捕获错误对象的不一样,因此咱们的 sendError
方法为了更方便的或取请求参数、处理响应以及更好的复用,将参数设置为请求对象、响应对象和错误对象。
Server.prototype.sendError = function (req, res, err) {
// 打印错误对象,方便调试
console.log(chalk.red(err));
// 设置错误状态码并响应 Not Found
res.statusCode = 404;
res.end("Not Found");
}复制代码
在渲染文件夹详情以前咱们首先要作的就是异步读取文件目录,因此咱们一样使用 async
函数来实现,NodeJS 中有不少渲染页面的模板,咱们本次使用 ejs
,语法简单,比较经常使用,ejs
为第三方模块,使用前需安装,更详细的用法可参照 npm
官方文档 www.npmjs.com/package/ejs。
npm install ejs
sendDirDetails
的参数为请求对象、响应对象和 statObj
。
Server.prototype.sendDirDetails = async function (req, res, statObj) {
// 读取当前文件夹
let dirs = await fs.readdir(this.realPath);
// 构造模板须要的数据
dirs = dirs.map(dir => ({ name: dir, path: path.join(this.pathname, dir)}));
// 渲染模板
let pageStr = ejs.render(this.template, { dirs });
// 响应客户端
res.setHeader("Content-Type", "text/html;charset=utf8");
res.end(pageStr);
}复制代码
还记得 Server
类的实例属性 template
存储的就是咱们的模板(字符串),里面写的就是 ejs
的语法,咱们使用 ejs
模块渲染的 render
方法能够将模板中的 JS 执行,并用传给该方法的参数的值替换掉模板中的变量,返回新的字符串,咱们直接将字符串响应给客户端便可。
path
为超连接标签要跳转的路径,若是直接使用 dir
的值,多级访问仍是会在根目录去查找,因此路径非法会返回 Not Found
,咱们须要在每次访问的时候都将上一次访问的路径与当前访问的文件夹或文件名进行拼接,保证路径的正确性。
上面已经知道了该怎样使用 ejs
对模板进行渲染,也对模板构造了数据,接下来就是使用 ejs
的语法编写咱们的模板内容。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Server</title>
</head>
<body>
<%dirs.forEach(function (item) {%>
<li><a href="<%=item.path%>"><%=item.name%></a></li>
<%})%>
</body>
</html>复制代码
<% %>
包裹,使用 <%= %>
输出变量。
因为都是根据路径查找或操做文件目录并作出响应,sendFile
方法与 sendDirDetails
方法的参数相同,分别为 req
、res
和 statObj
。
Server.prototype.sendFile = function (req, res, statObj) {
// 设置和处理缓存
if (this.cache(req, res, statObj)) {
res.statusCode = 304;
return res.end();
}
// 建立可读流
let rs = fs.createReadStream(this.realPath);
// 响应文件类型
res.setHeader("Content-Type", `${mime.getType(this.realPath)};charset=utf8`);
// 压缩
let zip = this.compress(req, res, statObj);
if (zip) return rs.pipe(zip).pipe(res);
// 处理范围请求
if (this.range(req, res, statObj)) return;
// 响应文件内容
rs.pipe(res);
}复制代码
其实上面的方法经过在根目录执行 node index.js
启动服务后,经过咱们默认配置的地址访问服务器,表面上就已经实现了 http-server
的功能,可是咱们为了服务器的性能和功能更强大,又在这基础上实现了缓存策略、服务器压缩和处理范围请求的逻辑。
咱们将上面的三个功能分别抽离成了 Server
类的三个原型方法,cache
、compress
和 range
,而且这三个方法的参数都为 req
、res
和 statObj
。
咱们本次的缓存兼容 HTTP 1.0
和 HTTP 1.1
版本,而且同时使用强制缓存和协商缓存共同存在的策略。
Server.prototype.cache = function (req, res, statObj) {
// 建立协商缓存标识
let etag = statObj.ctime.toGMTString() + statObj.size;
let lastModified = statObj.ctime.toGMTString();
// 设置强制缓存
res.setHeader("Cache-Control", "max-age=30");
res.setHeader("Expires", new Date(Date.now() + 30 * 1000).toUTCString());
// 设置协商缓存
res.setHeader("Etag", etag);
res.setHeader("Last-Modified", lastModified);
// 获取协商缓存请求头
let { "if-none-match": ifNodeMatch, "if-modified-since": ifModifiedSince } = req.headers;
if (etag !== ifNodeMatch && lastModified !== ifModifiedSince) {
return false;
} else {
return true;
}
}复制代码
304
状态码,若是协商缓存失效则读取文件内容返回浏览器。
为了减小文件数据在传输过程当中消耗的流量和时间,咱们在浏览器支持解压的状况下使用服务器压缩功能,浏览器会在请求时默认发送请求头 Accept-Encoding
通知咱们的服务器当前支持的压缩格式,咱们要作的就是按照压缩格式的优先级进行匹配,按照最高优先级的压缩格式进行压缩,将压缩后的数据返回,并经过响应头 Content-Encoding
通知浏览器当前的压缩格式(压缩流的本质为转化流)。
Server.prototype.compress = function (req, res, statObj) {
// 获取浏览器支持的压缩格式
let encoding = req.headers["accept-encoding"];
// 支持 gzip 使用 gzip 压缩,支持 deflate 使用 deflate 压缩
if (encoding && encoding.match(/\bgzip\b/)) {
res.setHeader("Content-Encoding", "gzip");
return zlib.createGzip();
} else if (encoding && encoding.match(/\bdeflate\b/)) {
res.setHeader("Content-Encoding", "deflate");
return zlib.createDeflate();
} else {
return false; // 不支持压缩返回 false
}
}复制代码
当浏览器支持压缩时,compress
方法返回的为优先级最高压缩格式的压缩流,不支持返回 false
,存在压缩流,则将数据压缩并响应浏览器,与不压缩响应不一样的是,须要使用压缩流将可读流转化为可写流写入响应 res
中,因此可读流执行了两次 pipe
方法。
range
方法处理的场景为客户端发送请求只想获取文件的某个范围的数据,此时经过 range
方法读取文件范围对应的内容响应给客户端,经过响应头 Accept-Ranges
通知浏览器当前响应范围请求,经过响应头 Content-Range
通知客户端响应的范围以及文件的总字节数。
Server.prototype.range = function (req, res, statObj) {
// 获取 range 请求头
let range = req.headers["range"];
if (range) {
// 获取范围请求的开始和结束位置
let [, start, end] = range.match(/(\d*)-(\d*)/);
// 处理请求头中范围参数不传的问题
start = start ? ParseInt(start) : 0;
end = end ? ParseInt(end) : statObj.size - 1;
// 设置范围请求响应
res.statusCode = 206;
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Range", `bytes ${start}-${end}/${statObj.size}`);
fs.createReadStream(this.realPath, { start, end }).pipe(res);
return true;
} else {
return false;
}
}复制代码
range
方法默认返回值为布尔值,当不是范围请求时返回值为 false
,则直接向下执行 sendFile
中的代码,正常读取文件所有内容并响应给浏览器,若是是范围请求则会处理范围请求后在直接结束后返回 true
,会在 sendFile
中直接 return
,再也不向下执行。
http-server
其实是经过命令行启动、并传参的,咱们须要将咱们的程序与命令行关联,关联命令行只需如下几个步骤。
首先,在根目录 package.json
文件中加入 bin
字段,值为对象,对象内属性为命令名称,值为对应执行文件的路径。
{
"name": "yourname-http-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"chalk": "^2.4.1",
"commander": "^2.17.1",
"debug": "^3.1.0",
"ejs": "^2.6.1",
"mime": "^2.3.1",
"mz": "^2.7.0"
},
"bin": {
"yourname-http-server": "bin/yourname-http-server.js"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}复制代码
其次,在 yourname-http-server.js
文件中首行加入注释 #! /usr/bin/env node
,在命令行执行命令时,默认会以 Node 执行 yourname-http-server.js
文件。
最后,想要使用咱们的命令启动 yourname-http-server.js
文件,则须要将这条命令链接到全局(与 -g 安装效果相同),在当前根目录下执行如下命令。
npm link
yourname-http-server
时,Node 会默认执行 yourname-http-server.js
文件。
咱们如今知道在命令行执行命令后用 Node 启动的文件为 yourname-http-server.js
,在启动文件时咱们应该启动咱们的服务器,并结合 commander
模块的参数解析,则须要用命令行传递的参数替换掉 config.js
中的默认参数。
const commander = require("commander");
const Server = require("../index");
// 增长 How to use
commander.on("--help", function () {
console.log("\r\n How to use: \r\n")
console.log(" zf-server --port <val>");
console.log(" zf-server --host <val>");
console.log(" zf-server --dir <val>");
});
// 解析 Node 进程执行时的参数
commander
.version("1.0.0")
.usage("[options]")
.option("-p, --port <n>", "server port")
.option("-o, --host <n>", "server host")
.option("-d, --dir <n>", "server dir")
.parse(process.argv);
// 建立 Server 实例传入命令行解析的参数
const server = new Server(commander);
// 启动服务器
server.start();复制代码
咱们以前把 config.js
的配置直接挂在了 Server
实例的 config
属性上,建立服务使用的参数也是直接从该属性上获取的,所以咱们要用 commander
对象对应的参数覆盖实例上 config
的参数,因此在建立 Server
实例时传入了 commander
对象,下面稍微修改 Server
类的部分代码。
class Server {
constructor(options) {
// 经过解构赋值将 options 的参数覆盖 config 的参数
this.config = { ...config, ...options }; // 配置
this.template = templateStr; // 模板
}
}复制代码
执行下面命令,并经过浏览器访问 http://127.0.0.1:4000 来测试服务器功能。
yourname-http-server --port 4000 --host 127.0.0.1
因为 JS 是单线程的,在命令行输入命令启动服务的同时不能去作其余的事,此时要靠多进程来帮助咱们打开浏览器,在 JS 中开启一个子进程来打开浏览器。
const commander = require("commander");
const Server = require("../index");
// 增长 How to use
commander.on("--help", function () {
console.log("\r\n How to use: \r\n")
console.log(" zf-server --port <val>");
console.log(" zf-server --host <val>");
console.log(" zf-server --dir <val>");
});
// 解析 Node 进程执行时的参数
commander
.version("1.0.0")
.usage("[options]")
.option("-p, --port <n>", "server port")
.option("-o, --host <n>", "server host")
.option("-d, --dir <n>", "server dir")
.parse(process.argv);
// 建立 Server 实例传入命令行解析的参数
const server = new Server(commander);
// 启动服务器
server.start();
// ********** 如下为新增代码 **********
let { exec } = require("child_process");
// 判断系统执行不一样的命令打开浏览器
let systemOrder = process.platform === "win32" ? "start" : "open";
exec(`${systemOrder} http://${commander.localhost}:${commander.port}`);
// ********** 以上为新增代码 **********复制代码
在发布咱们本身实现的 npm
模块以前须要先作一件事,就是解除当前模块与全局环境的 link
,咱们能够经过两种方式,第一种方式是直接到系统存储命令文件的文件夹删除模块对应命令的 yourname-http-server.cmd
文件,第二种方式是在模块根目录启动命令行并输入以下命令。
npm unlink
输入下面命令进行登陆:
npm login
登陆成功后执行下面命令进行发布:
npm publish
发布成功后再次使用本身的模块须要经过 npm
下载并全局安装,命令以下:
npm install yourname-http-server -g
任意文件夹内打开命令行,并执行命令启动服务验证。
nrm
切换过其余的源,必须切换回 npm
,再进行登陆和发布操做。
其实咱们实现的静态服务器核心还在于处理请求和响应的逻辑上,只是再也不手动输入 node
命令启动,而是借助一些第三方模块关联到了命令行并经过命令启动,开发其余类型的命令行工具也须要借助这些第三方模块,静态服务器只是其中之一,其实相似这种命令行工具在开发的角度来说属于 “造轮子” 系列,能够独立开发命令行工具是一个成为前端架构的必备技能,但愿经过本篇文章能够了解命令行工具的开发流程,在将来 “造轮子” 的道路上提供帮助。