NodeJS 创建静态资源服务器

最近,学习了一下 NodeJS 自己研究了一个静态资源服务器写的不好,希望高手多多指教

目录结构

在这里插入图片描述

文件详解

.gitignore 文件

这个文件主要是我们将本地项目上传到 GitHub 上时,忽略的文件,因为 GitHub 是我们项目代码的托管平台而不是运行平台,因此我们并不需要将运行所需要的一些包上传到 GitHub 上,例如:node_modules

.gitignore 文件有一些规则,在这里我只说几个常用的规则,详细的内容有兴趣的可以去官网查看

  1. *-- 代表任意个字符
  2. ?-- 匹配任意一个字符
  3. ** – 匹配多级目录
  4. 匹配模式前加 ‘/’ 代表项目根目录下的此文件或文件夹
  5. 匹配模式最后加 ‘/’ 代表此文件夹下的文件或文件夹
  6. 匹配模式前加 ‘!’ 代表取反

.gitignore 文件在我们创建 git 仓库时会自动生成,之后我们可以根据需求进行修改,当然我们也可以手动创建

以下是我的 .gitignore 配置

logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
node_modules/
*.swp
.idea/
.DS_Store
build/

.npmignore 文件

了解了上面的 .gitignore 文件,我相信这个文件也不难理解,这个文件就是当我们将项目上传到 npm 上时所需要忽略的内容

具体的内容有兴趣的也可以自己去官方文档查看

但是需要说明的一点是如果不设置 .npmignore 文件当你将项目上传到 npm 上时会自动读取 .gitignore 中的配置,并且 .npmignore 文件是自动忽略 node_modules 的

以下是我的 .npmignore 文件的配置

// 如果项目没有经过构建,则需要将 src 文件夹上传到 npm 不能忽略,由于我的没有经过构建所以此处不忽略 src
.editorconfig
.eslintignore
.eslintrc.js
test

README 文件

readme 是一个 MarkDown 文档,主要用于编写项目的介绍、安装方法以及使用方法等,具体内容大家可以自行编辑

附上我的 readme 文件供大家参考

	# anydoor

	Tiny NodeJS Static Web Server

	## 安装

	```
	npm i -g anydoor
	```

	## 使用方法

	```
	anydoor  # 把当前文件夹作为静态资源服务器的根目录

	anydoor -p 8080 # 设置端口号

	anydoor -h localhost # 设置 host

	anydoor-d /user # 设置根目录为 /user
	```

LICENSE 文件

此文件中涉及的是一些版权信息,在创建 git 项目时自动生成的,不用管它

.eslintrc.js 文件

eslint 是 JavaScript 代码的检查工具,我们可以通过一些配置来对代码进行一定的约束,比如我们可以禁止使用 console、alert,以及规定使用的 JavaScript 的版本等,详细的不在赘述,感兴趣的可以查看文档

我们在这简要的说一下 eslint 的规则 ID 以及如何使用注释关闭某些规则

  • 规则 ID
    1. off 或 0 – 关闭规则
    2. warn 或 1 – 开启规则,使用警告级别的错误
    3. error 或 2 – 开启规则,使用错误级别的错误
  • 使用注释关闭某些规则
/* eslint-disable */
   alert('foo');
/* eslint-enable */

也可以对指定的规则启用或禁用警告

/* eslint-disable no-alert,no-console */
alert('foo');
console.log('bar');
/* eslint-enable no-alert,no-console  */

如果在整个文件范围内禁止规则出现警告,将 /* eslint-disable */ 块注释在文件顶部

/* eslint-disable */

alert('foo');

也可以对整个文件启用或禁用警告

/* eslint-disable  no-alert */

alert('foo');

可以在你的文件中使用一下格式的行注释或块注释在某一特定行上禁用所有规则

alert('foo');        // eslint-disable-line

// eslint-disable-next-line
alert('foo'); 

/* eslint-disable-next-line */
alert('foo'); 

alert('foo');     /* eslint-disable-line */

这两个规则是相对比较重要的,当然 eslint 还有许多的规则,如果用到可以查文档去了解

还是附上我的 eslint 代码

module.exports = {
    "extends": ["eslint:recommended"],
    "rules": {
        "no-console": ["error", {
            "allow": ["warn", "error", "info"]
        }]
    },
    "parser": "babel-eslint",
    "parserOptions": {
        "ecmaVersion": 6,
        "sourceType": "script"
    },
    "globals": {},
    "env": {
        "node": true,
        "es6": true,
        "mocha": true
     }
};

注:我们可以通过 eslint --init 进行初始化 eslint

.eslintignore 文件

顾名思义这是针对 eslint 的忽略文件,具体作用与规则和 npmignore 以及 gitignore 一样

附上我的配置

node_modules
build/
test

.editorconfig 文件

在开发中,不同的团队的代码风格不同,使用不同编辑器也会使得代码风格有一些不同,如果设置了 editorconfig 文件,只要是支持 editorconfig 配置的编辑器都会将我们的代码按照配置的信息进行格式化,具体的使用可以去查询文档

附上我的配置文件

# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org

root = true

# Apply for all files
[*]

charset = utf-8

indent_style = space
indent_size = 2

end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.json]
indent_size = 4

package.json 文件

先附上我 package.json 文件,之后再来说明它的作用

{
  "name": "anydoor",
  "version": "0.1.0",
  "description": "Tiny NodeJS Static Web server",
  "main": "src/app.js",
  "bin": {
    "anydoor": "bin/anydoor"
  },
  "scripts": {
    "lint": "eslint .",
    "fix": "eslint --fix ."
  },
  "pre-commit": [
    "fix",
    "lint"
  ],
  "author": "liuyong",
  "license": "ISC",
  "dependencies": {
    "handlebars": "^4.0.11",
    "string_decoder": "^1.1.1",
    "yargs": "^12.0.1"
  },
  "devDependencies": {
    "babel-eslint": "^8.2.6",
    "chalk": "^2.4.1",
    "eslint": "^5.3.0",
    "pre-commit": "^1.2.2"
  }
}

package.json 文件主要是配置了一些我们的项目名称、版本、描述及一些启动信息、依赖文件等,我们可以通过 npm init 来生成此文件

"name": "anydoor",
 "version": "0.1.0",
 "description": "Tiny NodeJS Static Web server",
 "author": "liuyong",
 "license": "ISC",

这几个配置主要说明了项目的名称、版本、描述、作者以及版权等基本信息

"dependencies": { 
  "handlebars": "^4.0.11", 
  "string_decoder": "^1.1.1", 
  "yargs": "^12.0.1" 
},

这几个配置是我们项目在生产环境时所需依赖的其它 npm 包

"devDependencies": { 
  "babel-eslint": "^8.2.6", 
  "chalk": "^2.4.1", 
  "eslint": "^5.3.0", 
  "pre-commit": "^1.2.2" 
}

这几个配置是我们项目在开发时所需依赖的其它 npm 包

"scripts": { 
  "lint": "eslint .", 
  "fix": "eslint --fix ." 
},

script 中的配置是我们在命令行运行项目输入 npm run ×××,就会运行 ××× 后所对用的程序,此处我们配置了两个命令

  • line 是对所有的代码进行 eslint 检测,如果有错会在控制台输出错误信息
  • fix 针对有些错误 eslint 是可以通过 eslint – fix 命令进行自动修复,例如 ‘;’ 一类的就会自动加上
"pre-commit": [ 
  "fix", 
  "lint" 
],

pre-commit 是一个 npm 包,看名字我们也不难猜出它是在我们 commit 提交代码阶段做一些工作用的(在 devDependencies 中我们可以看见这个包),我们通过它再进行代码提交时强制检验代码是否符合 eslint 的规则,只有符合了才可以进行提交

"main": "src/app.js",

main 指定了程序的主入口文件,假设我们的包的名字叫做 foo ,当用户安装了这个模块并使用 require(‘foo’) 时,那么 require() 返回的内容就是 main 中指定的路径

"bin": { 
  "anydoor": "bin/anydoor" 
},

我们知道在使用一些包时,我们可以直接用包名来启动,这就是借助了 bin ,bin 中的 key 是我们的包名,而 value 则是项目执行的路径,这样当我们将包下载下来时,若是全局安装就会在我们在 bin 目录下创建一个软链(对于windows系统,默认会在C:\Users\username\AppData\Roaming\npm目录下);若是局部安装,则会在项目的 ./node_modules/.bin/ 目录下创建一个软链,这时我们在命令行就可以使用本例中的 anydoor 运行项目了

以上是一些基础的配置文件的相关内容,下面来看一下几个文件夹包含的内容

node_modules 文件夹

这个文件夹中包含的是我们项目运行所需要的所有 npm 包

bin 文件夹

这个文件夹中放的文件其实是一个包含一条脚本命令的文本文档,其内容如下

#!/usr/bin/env node        // #! 是一个 shebang,是 Linux 上的一个命令,指明这个文件使用 node 执行

require('../src/index');   // 将 index 文件引入(index 文件中是我们的入口 js 文件)

这个文件的主要作用就是使我们可以使用自定义的名称去启动项目,而不必须使用 node 去启动,例如此项目中我们就可以使用 bin/anydoor 来启动(windows 中不能在命令行直接运行,需要使用 git Bush 来启动),上面的 package.json 文件中我们有这么一个命令

"bin": { 
  "anydoor": "bin/anydoor" 
},

此时,我们就能真正明白这条命令的意思了,当我们用 npm 安装了 anydoor 这个包后,我们使用 anydoor 启动时,它就会找到我们 bin 文件夹下的 anydoor 文件,之后就会自动调用 node 去启动

src 文件夹

这里面存放的是我们的主要逻辑代码,其中包括 config(包含一些默认的配置)、helper(包含一些组件)、template(包含静态页面模板文件)、app.js(定义 server 服务)以及 index.js(入口 js 文件)

config 文件夹

这里面包含一个 defaultConfig.js 文件,它定义了一些基本配置并将其暴露出来

module.exports = {
  root:process.cwd(),             // 获取 NodeJS 运行的目录
  hostname:'127.0.0.1',           // 自定义域名 
  port:9527,                      // 自定义端口号
  compress:/\.(html|js|css|md)/,  // 自定义压缩文件的类型
  cache:{                         // 自定义缓存相关
    maxAge: 600,
    expires: true,
    cacheControl: true,
    lastModified: true,
    etag: true
  }
};
app.js
const http = require('http');         // 引入内置模块 http
const chalk = require('chalk').constructor({ enabled: true });  // 一个 npm 包,可以改变输出的内容的颜色
const path = require('path');         // 引入内置模块 path 
const conf = require('./config/defaultConfig');   // 引入 defaultConfig 文件
const route = require('./helper/router');         // 引入 router 文件
const openUrl = require('./helper/openUrl');      // 引入 openUrl 文件

// 创建一个 server 的 class 类
class Server {
  constructor(config){
    this.conf = Object.assign({},conf,config);   // 使用 Object.assign 方法将默认的数据与传入的数据进行合并
  }

  start(){
    // 创建 server 服务
    const server = http.createServer((req,res) => {
      // 使用 path.join 将路径进行合并
      const filePath = path.join(this.conf.root,req.url);
      route(req,res,filePath,this.conf);
    });
    // 监听 server 服务
    server.listen(this.conf.port,this.conf.hostname,() => {
      // 使用模板字符串将域名与端口号组合起来
      const addr = `http://${this.conf.hostname}:${this.conf.port}`;
      console.info(`Server started at ${chalk.green(addr)}`);
      // 强制使用 addr 地址打开浏览器
      openUrl(addr)
    });
  }
}
// 将 server 暴露出去
module.exports = Server;
helper 文件夹

router.js – 判断文件类型,压缩文件,使用缓存

const fs = require('fs');           // 引入内置的 fs 模块
const path = require('path');       // 引入内置的 path 模块
const Handlebars = require('handlebars');   // 引入 handlebars 模块(模板引擎)
const promisify = require('util').promisify;  // 引入内置的 util 模块中的 promisify 进行异步解决回调地狱
const stat = promisify(fs.stat);      // 使用 promisify 方法调用 fs 模块的 stat 方法获取文件状态信息
const readdir = promisify(fs.readdir);   // 使用 promisify 方法调用 fs 模块的 readdir 方法读取文件的目录内容
const tplpath = path.join(__dirname,'../template/dir.tpl');   // 获取模板文件的绝对路径
const source = fs.readFileSync(tplpath);  // 同步读取模板文件
const template = Handlebars.compile(source.toString());  // 预编译模板
const mime = require('../helper/mime');   // 引入 mime 文件
const compress = require('./compress');   // 引入 compress 文件 
const range = require('./range');         // 引入 range 文件
const cache = require('./cache');         // 引入 cache 文件

// 使用异步的方法获取文件信息及文件目录
module.exports = async function (req,res,filePath,conf) {
  try {
    const stats = await stat(filePath);
    // 判断是否为文件
    if(stats.isFile()){
      const contentType = mime(filePath);  // 自定义改变 Content-type 的值
      res.setHeader('Content-Type',contentType); // 设置响应头的 Content-type
      // 判断是否读取缓存中的内容,如果 cache() 为 true,则使用缓存否则不使用
      if(cache(stats,req,res)){
        res.statusCode = 304;
        res.end();
        return;
      }
      let rs;
      const {code,start,end} = range(stats.size,req,res);  // 显示的内容的范围 
      // 如果返回的 code = 200,则说明无限制,直接读取完整文件
      if(code === 200){
        res.statusCode = 200;
        rs = fs.createReadStream(filePath);
      }else{
        // 否则获取 start-end 的内容
        res.statusCode = 206;
        rs = fs.createReadStream(filePath,{start,end});
      }
      // 判断是否可以压缩(只有满足了 defaultConfig 中配置的 compress 才会进行压缩)
      if(filePath.match(conf.compress)){
        rs = compress(rs,req,res)
      }
      // 将 rs 输出到 res 中
      rs.pipe(res);
    }else if(stats.isDirectory()){
      // 判断是否为文件夹
      const files = await readdir(filePath);
      res.statusCode = 200;
      res.setHeader('Content-Type','text/html');
      const dir = path.relative(conf.root,filePath);
      // 设置模板数据
      const data = {
        title:path.basename(filePath),
        dir:dir ? `/${dir}` : '',
        files:files.map(file => {
          return {
            file,
            icon:mime(file)
          }
        })
      };
      // 将模板放入到 res 中
      res.end(template(data));
    }
  }catch (ex){
    // 错误的情况
    console.error(ex);
    res.statusCode = 404;
    res.setHeader('Content-Type','text/html');
    res.write('<head><meta charset="UTF-8"></head>');
    res.end(`${filePath} is not a directory or file\n ${ex.toString()}`);
  }
};

mime.js – 修改 content-type 的文件类型

const path = require('path');

// 文件的类型
const mimeTypes = {
  'css': 'text/css',
  'gif': 'image/gif',
  'html': 'text/html',
  'ico': 'image/x-icon',
  'jpeg': 'image/jpeg',
  'jpg': 'image/jpeg',
  'js': 'text/javascript',
  'json': 'application/json',
  'pdf': 'application/pdf',
  'png': 'image/png',
  'svg': 'image/svg+xml',
  'swf': 'application/x-shockwave-flash',
  'tiff': 'image/tiff',
  'txt': 'text/plain',
  'wav': 'audio/x-wav',
  'wma': 'audio/x-ms-wma',
  'wmv': 'video/x-ms-wmv',
  'xml': 'text/xml',
  'tpl':'text/html',
  'dir':'dir'
};

module.exports = (filePath) => {
  // 获取路径的扩展名
  let ext = path.extname(filePath).split('.').pop().toLowerCase();
  // 如果返回空则说明不是文件而是一个文件夹或者'.text'这种类型的文件
  if(!ext){
    if(filePath.indexOf('.') !== -1){
      ext = filePath
    }else{
      ext = 'dir'
    }
  }
  // 根据结果返回不同的类型
  return mimeTypes[ext] || mimeTypes['txt']
};

cache.js – 设置缓存的方法

const {cache} = require('../config/defaultConfig');

// 设置响应头缓存时间
function refreshRes(stats,res) {
  // ES6 语法解构赋值获取 maxAge,expires,cacheControl,lastModified,etag
     // 这几种都是可以用做缓存处理的字段  
  const {maxAge,expires,cacheControl,lastModified,etag} = cache;

  // 根据不同的情况设置响应头
  if(expires){
    // expires 通过当前时间 + 最长缓存过期事件来决定是否使用缓存
    res.setHeader('Expires',new Date(Date.now() + maxAge * 1000).toUTCString());
  }

  if(cacheControl){
    // cache-control 通过设置最长过期事件来决定是否使用缓存
    res.setHeader('Cache-Control',`public,max-age=${maxAge}`);
  }

  if(lastModified){
    // last-modified 通过设置文件最后修改的时间再与请求头对比决定是否使用缓存
    res.setHeader('Last-Modified',stats.mtime.toUTCString());
  }

  if(etag){
    // etag 通过设置文件大小加修改时间再与请求头对比决定是否使用缓存
    res.setHeader('ETag',`${stats.size}-${stats.mtime.toUTCString()}`);
  }
}

// 暴露出一个方法决定是否使用缓存
module.exports = function isFresh(stats,req,res) {
  refreshRes(stats,res);

  // 获取请求头的 if-modified-since 与 if-none-match
  const lastModified = req.headers['if-modified-since'];
  const etag = req.headers['if-none-match'];

  if(!lastModified && !etag){
    return false
  }

  if(lastModified && lastModified !== res.getHeader('Last-Modified')){
    return false
  }

  if(etag && etag !== res.getHeader('ETag')){
    return false
  }

  return true
};

range.js – 限制请求范围的方法(常用于断点续传)

module.exports = (totalSize , req ,res) => {
  // 获取请求头中的 range
  const range = req.headers['range'];
  // 如果不存在则不进行限制
  if(!range){
    return {code: 200};
  }
  
  // 如果存在则获取 bytes = .. 并将其分为数组
  const sizes = range.match(/bytes=(\d*)-(\d*)/);
  const end = sizes[2] ? parseInt(sizes[2]) : totalSize - 1;
  const start = sizes[1] ? parseInt(sizes[1]) : 0;

  // 排除一些设置错误的情况
  if(start > end || start < 0 || end > totalSize){
    return {code: 200};
  }

  // 设置响应头
  res.setHeader('Accept-Ranges','bytes');
  res.setHeader('Content-Range',`bytes ${start}-${end}/${totalSize}`);
  res.setHeader('Content-Length',end - start);

  return{
    code: 206,
    start,
    end
  }
};

注:我们在测试时,想要在浏览器改变服务器的 range 是比较难的,但我们可以通过 curl 这个命令来实现(使用 npm install curl 安装),之后执行命令 curl -r 0-10 -i http://127.0.0.1:9527/LICENSE 就可以了,如下图
在这里插入图片描述

compress.js – 判断文件是否可压缩的方法

const {createGzip , createDeflate} = require('zlib'); // 引入内置的 zlib 模块,其中包含压缩方法

module.exports = (rs,req,res) => {
  // 获取请求头中的 accept-encoding
  const acceptEncoding = req.headers['accept-encoding'];
  // 如果 accept-encoding 不存在或者不是 gzip 或 deflate 则不进行压缩
  // 压缩的类型很多,这里只举 gzip 与 deflate 为例
  if(!acceptEncoding || !acceptEncoding.match(/\b(gzip|deflate)\b/)){
    return rs
  }else if(acceptEncoding.match(/\b(gzip)\b/)){
    // 优先选择使用 gzip 压缩
    res.setHeader('Content-Encoding','gzip');
    // 将压缩后的文件输出
    return rs.pipe(createGzip());
  }else if(acceptEncoding.match(/\b(deflate)\b/)){
    // 如果不支持 gzip 压缩则使用 deflate
    res.setHeader('Content-Encoding','deflate');
    return rs.pipe(createDeflate());
  }
};

openUrl.js – 自动打开浏览器的方法

const {exec} = require('child_process'); // 引入内置的 child_process 模块

// 判断是 ios 系统还是 windows 系统
module.exports = url => {
  switch (process.platform){
    case 'darwin':  // ios 系统
      exec(`open ${url}`);
      break;
    case 'win32':   // windows 系统
      exec(`start ${url}`);
      break;
  }
};
template 文件夹

dir.tpl – 模板引擎文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <!-- 设置标题 -->
  <title>{{title}}</title>
  <style>
    body{
      margin: 30px;
    }
    a{
      display: block;
      font-size: 20px;
    }
  </style>
</head>
<body>
<!-- 为文件加上跳转 -->
{{#each files}}
  <a href="{{../dir}}/{{file}}">【{{icon}}】{{file}}</a>
{{/each}}
</body>
</html>

注:handlebars 模板引擎的使用就不赘述了,感兴趣的可以去官网查看

index.js – 配置自定义参数以及调用 server 类

// npm 上的一个包,是一个强大的选项解析器可以解析我们传入的参数
const yargs = require('yargs');
const Server = require('./app');

const argv = yargs
  .usage('anywhere [options]')
  .option('p',{
    alias:'port',
    describe:'端口号',
    default:9527
  })
  .option('h',{
    alias:'hostname',
    describe:'host',
    default:'127.0.0.1'
  })
  .option('d',{
    alias:'root',
    describe:'root path',
    default:process.cwd()
  })
  .version()
  .alias('v','version')
  .help()
  .argv;

const server = new Server(argv);
// 执行服务
server.start();

注:详细使用可以去官网查看

猜你喜欢

转载自blog.csdn.net/weixin_39749820/article/details/82798168