手把手教你如何实现一个基于node的静态文件服务器
首先来看看我要准备给大家写的静态文件服务器都要实现哪些功能,然后根据具体的功能我们来一一的介绍
支持以下功能
- 支持输出日志debug
- 读取静态文件
- 文件的读取 MIME类型支持
- 文件夹列表展示 handlebars模板引擎
- 缓存支持/控制
- Range支持,断点续传
- 支持gzip和deflate压缩
- 发布为可执行命令并可以后台运行,可以在全局通过下面的命令来设置根目录端口号和主机名:myselfsever -d指定静态文件的根目录 -p指定端口号 -o指定监听的主机
启动
npm install // 安装依赖
npm link // 创建连接
myselfserver // 在任意目录下启动服务
// 访问 localhost:8080
其次我们先大致看一下我们的服务器的大致结构
image接下来我们来介绍我们这个服务器
要想实现一个服务器的功能,我们往往需要设置一下服务器的主机名,端口号,静态文件根目录等信息,在这里我们的config.js帮我们实现了这一配置,当然了这是基础的配置,后面我们还会讲到如何通过命令行来更改配置。基础配置如下:
let path = require('path');
let config = {
host: 'localhost',// 监听主机
port: 8080,// 主机端口号
root: path.resolve(__dirname, '..')// 静态文件根目录
}
module.exports = config;
配置好之后接下来我们就需要写一个我们的服务了,服务的代码会在我们的app.js中体现,其中包括我们上面列举的几乎所有的功能,我们先来看一下app.js的代码结构
const fs = require('fs');
const url = require('url');
const http = require('http');
const path = require('path');
const config = require('./config');
class Server {
constructor(argv) {
// 生产handlebars模板
this.list = list()
// 处理命令行中设置的参数,来重写基本参数
this.config = Object.assign({}, config, argv)
}
start() {
/*启动http服务*/
let server = http.createServer();
server.on('request', this.request.bind(this));
let url = `http://${config.host}:${config.port}`;
server.listen(this.config.port, ()=>{
// (1)支持输出日志debug 在下文中会讲一下使用它的注意事项
debug(`server started at ${chalk.green(url)}`);
})
}
/*(2)读取静态文件*/
async request(req,res) {
// 因为响应可能出错,所以在我们的代码中这里会用try catch包一下。
// try
// 文件夹: (2.1)模板引擎渲染展示文件列表 这里用handlebars模板引擎来渲染
// 文件:-->sendFile
// catch
// 处理错误交给-->sendError
}
/* send file to browser*/
sendFile (req, res, filePath, statObj) {
// (2.2)处理文件并发送给浏览器 添加MIME类型支持
}
/* handle error*/
sendError (error, req, res) {
// 公用的的错误处理函数
}
/*cache 是否走缓存*/
isCache (req, res, filePath, statObj) {
// (3)处理缓存
}
/*broken-point continuingly-transferring 断点续传*/
rangeTransfer (req, res, filePath, statObj) {
// (4)支持断点续传
}
/*compression 压缩*/
compression (req, res) {
// (5)支持gzip和deflate压缩
}
}
module.exports = Server;
(6)myselfsever -d指定静态文件的根目录 -p指定端口号 -o指定监听的主机 这个命令实现的脚本代码在 bin目录下的commond文件中
开始进入细节分析阶段
注:每用到一个模块的时候记得提前安装到本地
(1)如何打印错误日志---debug
const debug = require('debug'); // 安装引入debug包
debug('staticserver:app');
// debug 第三方模块返回的是一个函数,该函数执行需要传入两个参数,一个是你当前项目的名称,即package.json 中的name值,第二个参数是你的模块的服务入口文件的名称。在咱们的这个项目中是app.js
当我们项目中要使用debug错误输出模块时,需要配置环境变量我们的debug日志才会被输出
环境变量的配置方法
windows上配置环境变量
- 配置单个文件,即只有当前文件中的debug日志会输出 (优势:可以灵活的配置,输出自己想要输出的文件的debug日志)
$ set DEBUG=staticserver:app
- 配置整个项目,整个项目中的debug日志都会被输出
$ set DEBUG=staticserver:*
mac上配置环境变量
$ export DEBUG=staticserver:app
$ export DEBUG=staticserver:*
(2)读取静态文件
如果是文件夹的话显示文件列表,这里我们用到了handlebars模板引擎
const handlebars = require('handlebars'); // 模版引擎
// 调用handlebars.compile方法生成一个模板
function list () {
let tmpl = fs.readFileSync(path.resolve(__dirname,'template','list.html'),'utf8');
return handlebars.compile(tmpl)
}
接下来我们来看访问的路径是文件的情况。这时候我们会根据文件的后缀的不同返回不同的响应类型,这时我们就用到了我们的mime模块
/*静态文件服务器*/
const { promisify, inspect } = require('util');
const mime = require('mime');
const stat = promisify(fs.stat);
const readdir = promisify(fs.readdir);
async request(req,res) {
// 先取到客户端想访问的路径
let { pathname } = url.parse(req.url);
// 如果访问的是favicon.ico 的话返回一个错误信息
if (pathname == '/favicon.ico') {
return this.sendError('not favicon.ico',req,res)
}
// 获取访问的文件或文件夹的路径
let filePath = path.join(this.config.root, pathname);
try{
// 判断访问的路径是文件夹 还是文件
let statObj = await stat(filePath);
if (statObj.isDirectory()) {// 如果是目录的话 应该显示目录下面的文件列表 否则显示文件内容
let files = await readdir(filePath);
files = files.map(file => ({
name: file,
url: path.join(pathname, file)
}))
let html = this.list({
title: pathname,
files,
});
res.setHeader('Content-Type', 'text/html');
res.end(html)
} else {
// 如果是文件的话显示文件内容
this.sendFile(req, res, filePath, statObj)
}
}catch (e) {
debug(inspect(e)); // 把一个对象转化为字符串,因为有的tosting会生成object object
this.sendError(e, req, res);
}
}
// 接下来我们来看访问的路径是文件的情况。这时候我们会根据文件的后缀的不同返回不同的响应类型,这时我们就用到了我们的mime模块
sendFile (req, res, filePath, statObj) {
// 如果缓存存在的话走缓存
if(this.isCache(req, res, filePath, statObj)){
return;
}
res.statusCode = 200; // 可以省略
res.setHeader('Content-Type', mime.getType(filePath) + ';charset=utf-8');
let encoding = this.compression(req,res);
// 是不是需要压缩
if(encoding) {
// 在这里使用断点续传
this.rangeTransfer(req, res, filePath, statObj).pipe(encoding).pipe(res)
} else {
this.rangeTransfer(req, res, filePath, statObj).pipe(res)
}
}
(3)缓存支持和控制
要想理解下面缓存是否存在,缓存是否有效来判断要不要走缓存,大家可以先看一下这篇文章node中的缓存机制可以加深对缓存的理解
/*cache 是否走缓存*/
isCache (req, res, filePath, statObj) {
let ifNoneMatch = req.headers['if-none-match'];
let ifModifiedSince = req.headers['if-modified-since'];
res.setHeader('Cache-Control','private,max-age=10');
res.setHeader('Expires',new Date(Date.now() + 10*1000).toGMTString);
let etag = statObj.size;
let lastModified = statObj.ctime.toGMTString();
res.setHeader('Etag',etag)
res.setHeader('Last-Modified',lastModified);
if(ifNoneMatch && ifNoneMatch != etag) {
return false
}
if(ifModifiedSince && ifModifiedSince != lastModified){
return false
}
if(ifNoneMatch || ifModifiedSince) {
res.writeHead(304);
res.end();
return true
} else {
return false
}
}
(4)Range支持,断点续传
该选项指定下载字节的范围,常应用于分块下载文件
range的表示方式有多种,如100-500,则指定从100开始的400个字节数据;-500表示最后的500个字节;5000-表示从第5000个字节开始的所有字节
另外还可以同时指定多个字节块,中间用","分开
服务器告诉客户端可以使用range response.setHeader('Accept-Ranges', 'bytes')
Server通过请求头中的Range:bytes=0-xxx来判断是否是做Range请求,如果这个值存在而且有效,则只发回请求的那部分文件内容,响应的状态码变成206,如果无效,则返回416状态码,表明Request Range Not Satisfiable
/*broken-point continuingly-transferring 断点续传*/
rangeTransfer (req, res, filePath, statObj) {
let start = 0;
let end = statObj.size-1;
let range = req.headers['range'];
if(range){
res.setHeader('Accept-range','bytes');
res.statusCode=206// 返回整个内容的一块
let result = range.match(/bytes=(\d*)-(\d*)/);
start = isNaN(result[1]) ? start : parseInt(result[1]);
end = isNaN(result[2]) ? end : parseInt(result[2]) - 1
}
return fs.createReadStream(filePath, {
start,
end
})
}
(5)支持gzip和deflate压缩
服务器会根据客户端请求中的req.headers['accept-encoding']这个字段中的值来判断客户端支持哪种解压类型来进行压缩的
/*compression 压缩*/
compression (req, res) {
let acceptEncoding = req.headers['accept-encoding'];//163.com
if(acceptEncoding) {
if(/\bgzip\b/.test(acceptEncoding)){
res.setHeader('Content-Encoding','gzip');
return zlib.createGzip();
} else if(/\bdeflate\b/.test(acceptEncoding)) {
res.setHeader('Content-Encoding','deflate');
return zlib.createDeflate();
} else {
return null
}
}
}
(6)发布为可执行命令并可以后台运行,可以在全局通过下面的命令来设置根目录端口号和主机名:myselfsever -d指定静态文件的根目录 -p指定端口号 -o指定监听的主机
这个功能是在我们的bin目录下面的command 文件中实现的。
文件开头的#! /usr/bin/env node 这句命令是告诉该脚本用哪种程序来执行,详细可看这边文章Node.js 命令行程序开发教程
#! /usr/bin/env node
let yargs = require('yargs');
let Server = require('../src/app.js')
let argv = yargs.option('d', {
alias: 'root',
demand: false,
description: '请配置监听静态文件目录',
default: process.cwd(),
type: 'string'
}).option('o', {
alias: 'host',
demand: false,
description: '请配置监听的主机',
default: 'localhost',
type: 'string'
}).option('p', {
alias: 'port',
demand: false,
description: '请配置监听的主机的端口号',
default: 8080,
type: 'number'
}).usage('myselfsever -d / -p 8080 -o localhost')
.example(
'myselfsever -d / -p 9090 -o localhost', '在本机器的9090端口上监听客户端发来的请求'
).help('h').argv;
let server = new Server();
server.start(argv);
每天都进步一点;不要畏惧陌生的事物,每天都学习一点;