实现一个node服务器

141 阅读7分钟

实现一个node服务器

常用的静态服务器:http-server

  • npm i http-server -g
  • http-server
  • 会以目录当做一个静态资源服务器

实现并拓展

功能点

  • 本地命令启动
    • -h 提示
  • 静态资源托管
    • 支持自定义端口与地址
    • 文件压缩
    • 模板引擎渲染文件夹内容
  • mock拦截,可以自定义页面返回或接口返回
  • 图片防盗
  • 跨域资源共享
  • 文件缓存:强缓存+协商缓存

前置条件

  • node版本16
  • npm init -y 初始化项目

package.json

  • 我的package.json文件
  • name属性就是一会儿要执行的脚本名称
  • bin则是要执行的文件
{
  "name": "httpserver",
  "version": "1.0.0",
  "description": "",
  "bin": "./bin/www",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^9.4.0",
    "debug": "^4.3.4",
    "ejs": "^3.1.8",
    "mime": "^3.0.0",
    "url-parse": "^1.5.10"
  },
  "devDependencies": {
    "@types/node": "^18.0.6"
  }
}
  • npm i

bin目录

  • 新建bin目录
config.js
//配置文件
module.exports = {
  port: {
    option: "-p, --port <port>",
    description: "端口号,默认8080",
    default: 8080,
  },
  directory: {
    option: "-d, --directory <dir>",
    description: "地址,默认当前目录",
    default: process.cwd(),
  },
};

目录下新建www文件
  • 总入口,脚本执行文件
#! /usr/bin/env node

const { program } = require("commander");
const pkg = require("../package.json");
const config = require("./config");
//用法提示
program.name("httpserver").usage("[options]").version(pkg.version);
//配置项提示与设置
let defaultValue = {};
Object.entries(config).forEach(
  ([key, { option, description, default: val }]) => {
    defaultValue[key] = val;
    program.option(option, description);
  }
);
// program
//   .option("-p, --port <port>", "端口号,默认8080")
//   .option("-d, --directory <dir>", "地址,默认当前目录");

program.parse();
//命令行解析的值,默认是{}
const options = program.opts(process.argv);
//合并
const userConfig = Object.assign(defaultValue, options);

//创建服务
const createServer = require("../src/server");
createServer(userConfig);

src

server.js
  • 逻辑全在这里面
//内置模块-------------
const http = require("http");
const path = require("path");
const url = require("url");
const { createReadStream, existsSync, readFileSync } = require("fs");
const fs = require("fs/promises");
const os = require("os");
const queryString = require("querystring");
const zlib = require("zlib");
//第三方模块--------------

const chalk = require("chalk"); //提示颜色
const parse = require("url-parse"); //解析url
const mime = require("mime"); //有一个getType方法可以根据地址获取类型
const debugDev = require("debug")("development");
const ejs = require("ejs");

class Server {
  constructor(userConfig = {}) {
    this.port = userConfig.port;
    this.directory = userConfig.directory;
    this.address = userConfig.address;
    this.template = readFileSync(
      path.resolve(__dirname, "template.html"),
      "utf8"
    );
    this.start();
  }
  /**
   * 判断是文件还是文件夹
   * @param {*} queryPath
   * @returns
   */
  async stat(queryPath) {
    try {
      let statObj = await fs.stat(queryPath);
      return statObj;
    } catch (err) {
      debugDev(err);
      return false;
    }
  }
  /**
   * 静态资源处理
   */
  async processAssets(pathname, req, res, statObj) {
    //拿到请求的资源路径
    const queryPath = path.join(this.directory, pathname);
    console.log(chalk.blue(pathname));
    try {
      let statObj = await fs.stat(queryPath);
      if (statObj.isFile()) {
        //返回文件
        this.sendFile(queryPath, req, res, statObj);
      } else {
        //展示文件夹
        this.sendDirectory(queryPath, statObj, req, res, pathname);
      }
    } catch (err) {
      this.sendError(err, res);
    }
  }
  /**
   * 设置跨域
   */
  cors(req, res) {
    if (req.headers.origin) {
      res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
      res.setHeader("Access-Control-Allow-Headers", "authorization"); //因为加了自定义header,所以服务端也要支持这个自定义头
      res.setHeader("Access-Control-Max-Age", 10);
      res.setHeader(
        "Access-Control-Allow-Methods",
        "GET,POST,DELETE,PUT,OPTIONS"
      );
      //如果是复杂请求,会发OPTIONS请求,不处理的话,会卡住,长时间pending状态
      //那么我们让他直接通过
      if (req.method === "OPTIONS") {
        res.end();
        return true;
      }
    }
  }
  /**
   * mock数据
   * @param {*} pathname
   * @param {*} req
   * @param {*} res
   * @param {*} query
   * @returns
   */
  async processData(pathname, req, res, query) {
    //将处理好的query放在req上
    req.query = query;
    //post请求需要用流来获取,所以用promise来写
    req.body = await new Promise((resolve, reject) => {
      const arr = [];
      req.on("data", (chunk) => {
        arr.push(chunk);
      });
      req.on("end", () => {
        // console.log(arr);
        let body = Buffer.concat(arr).toString();
        switch (req.headers["content-type"]) {
          case "application/json":
            //处理json
            body = JSON.parse(body);
            break;
          case "application/x-www-form-urlencoded":
            //处理表单,以&和=分割
            body = queryString.parse("a=1&b=2", "&", "=");
            break;
        }
        resolve(body);
      });
    });
    let mockPath = path.join(this.directory, "mock.js");
    if (existsSync(mockPath)) {
      //存在就加载
      let mockFn = require(mockPath);
      let flag = mockFn(pathname, req, res);
      //命中了就不走了
      return flag;
    }
  }
  /**
   * 处理客户端的请求和响应
   * 方法放在类本身上,省去了.bind(this)
   * @param {*} req
   * @param {*} res
   */
  handleRequest = async (req, res) => {
    const { pathname, query } = parse(req.url, true);
    //如果是OPTIONS请求,不往下走了----------
    if (this.cors(req, res)) return;
    //mock数据部分---------------
    //如果是mock数据,不往下走了
    if (await this.processData(pathname, req, res, query)) return;
    //静态资源部分---------------
    this.processAssets(pathname, req, res);
  };
  async sendDirectory(queryPath, statObj, req, res, pathname) {
    const homePath = path.join(queryPath, "index.html");
    if (existsSync(homePath)) {
      this.sendFile(homePath, req, res, statObj);
    } else {
      //拿到所有文件
      let dirs = await fs.readdir(queryPath);
      //拿到目录下所有文件的信息
      const fileStatus = await Promise.all(
        dirs.map(async (dir) => await fs.stat(path.join(queryPath, dir)))
      );
      dirs = dirs.map((dir, index) => {
        return {
          url: path.join(pathname, dir),
          dir,
          info: fileStatus[index].isFile() ? "文件" : "文件夹",
          size: fileStatus[index].size,
        };
      });
      //根据模板进行拼接
      const content = ejs.render(this.template, { dirs });
      res.setHeader("Content-Type", "text/html;charset=utf-8");
      res.end(content);
    }
  }
  /**
   * 压缩,如果支持压缩就返回一个转化流
   * 查看压缩的效果:
   *  打开浏览器控制台,点击NetWork,鼠标悬浮在size可以看到文件本身的大小
   * 这里需要注意两个点:
   *  header里node用的是小写
   *  压缩的时候,要设置解析方式,否则浏览器识别不了,Content-Encoding
   *  不要设置成Content-Type,常见面试题,为什么我点了一个页面,但是它直接下载了
   * @param {*} filename
   * @param {*} req
   * @param {*} res
   */
  compress(filename, req, res) {
    //常见的三种格式gzip, deflate, br
    let encoding = req.headers["accept-encoding"]; //拿到浏览器支持的压缩方式
    if (encoding) {
      if (encoding.includes("br")) {
        //是否支持br
        res.setHeader("Content-Encoding", "br");
        return zlib.createBrotliCompress();
      } else if (encoding.includes("gzip")) {
        res.setHeader("Content-Encoding", "gzip");
        return zlib.createGzip();
      } else if (encoding.includes("deflate")) {
        res.setHeader("Content-Encoding", "deflate");
        return zlib.createDeflate();
      }
    }
  }
  cache(filename, req, res, statObj) {
    res.setHeader("Cache-Control", "max-age=20");
    /**
     * 用户第一次访问的时候,可以加一个缓存字段,让浏览器在一定时间内不在请求服务器
     * 直接访问的哪怕有缓存也不生效,因为浏览器会给它加上一个0或no-cache,也就无法走强制缓存了
     */
    // Expries被淘汰了,因为不是很精准,它的缓存策略是拿本地请求时间与缓存时间对比,如果缓存时间超了,就重新拿资源,但本地时间是可以改的
    // res.setHeader("Expries", new Date(Date.now() + 10 * 1000).toGMTString());

    //设置强缓存,10秒缓存,但在谷歌浏览器下可能不生效
    //强缓存对于html来说不会生效
    //直接访问的资源不能缓存,因为浏览器会自动设置Cache-Control: max-age=0
    //强缓存的问题:
    //      如果在缓存期间,清除浏览器缓存,那么缓存失效
    //      如果在缓存期间,文件更新,那么客户端拿不到最新的资源
    //res.setHeader("Cache-Control", "max-age=10000");

    /**
     * 协商缓存
      * 方案1:
      *  在第一次请求的时候,将文件的修改时间通过Last-Modified给到客户端
      *  客户端在请求的时候,会通过If-Modified-Since带上这个Last-Modified时间
      *  服务端就可以进行校验,如果两个时间相同,返回304,客户端会自动去找缓存
      *  优点:
      *      可以缓存首页
      *       灵活
      * 缺点:以修改时间为基准,也不一定精准,因为一个如果是粘贴操作,我们一秒可能粘贴多次,这样就不精准了
      *     res.setHeader("Last-Modified", statObj.ctime.toGMTString());
            const ifModifiedSince = req.headers["if-modified-since"];
            console.log(ifModifiedSince);
            //当前时间
            let currentCtime = statObj.ctime.toGMTString();
            if (currentCtime === ifModifiedSince) {
            //对比下
                res.statusCode = 304;
                res.end();
                return;
              }


        方案2:
            以修改时间和文件大小联合对比,生成一个标识Etag
            那它也有对应的请求头,If-None-Match,同样是把上次给的Etag给到服务器
            返回也是一样的,304,浏览器自己找缓存
     */
    //大小+/+(时间戳 转16进制)
    const etag = statObj.size + "/" + statObj.ctime.getTime().toString(16);
    const ifNoneMatch = req.headers["if-none-match"];
    res.setHeader("Etag", etag);
    if (ifNoneMatch === etag) {
      res.statusCode = 304;
      res.end();
      return true;
    }
  }
  /**
   * 返回客户端文件内容
   * @param {*} queryPath
   * @param {*} req
   * @param {*} res
   * @param {*} statObj 文件信息
   */
  sendFile(filename, req, res, statObj) {
    //设置类型与内容解析方式
    res.setHeader("Content-Type", mime.getType(filename) + ";charset=utf-8");
    /**
     * 对图片做防盗链
     * 例子:
     *  a的地址:127.0.0.1:8080
     *  b的地址:dev.baidu.com
     *    那么a上有一些图片,b希望在它的网站中用a的图片,这个时候a又不想让他用,那么a就需要做限制了,这个可以称为防盗链
     */
    if (this.cache(filename, req, res, statObj)) return;
    if (/\.(jpeg|png|jpg)$/.test(filename)) {
      //如果是图片
      let referer = req.headers["referer"] || req.headers["referrer"];
      if (referer) {
        let host = "http://" + req.headers["host"];
        let r1 = url.parse(host).hostname;
        let r2 = url.parse(referer).hostname;
        if (r1 != r2) {
          //不一样,不给你返
          res.end("Not found");
          return;
        }
      }
    }
    //如果浏客户端支持压缩,那么就创建一个转化流来压缩,减少传输体积
    let stream = this.compress(filename, req, res);
    if (stream) {
      return createReadStream(filename).pipe(stream).pipe(res);
    }
    //防止大文件存在,所以使用流的方式
    createReadStream(filename).pipe(res);
  }
  /**
   * 错误处理
   * @param {*} err
   * @param {*} res
   */
  sendError(err, res) {
    debugDev(err);
    res.end("Not found");
  }
  /**
   * 启动服务
   */
  start() {
    const server = http.createServer(this.handleRequest);
    let port = this.port;
    const address = this.address.map(
      (item) => "http://" + item + ":" + chalk.green(port)
    );
    server.on("error", (err) => {
      if (err && err.code === "EADDRINUSE") {
        port += 1;
        server.listen(port);
      }
    });
    server.listen(port, () => {
      console.log(address.join("  "));
    });
  }
}
module.exports = (userConfig) => {
  //获取操作系统信息,拿到里面ipv4的ip
  const intsrface = os.networkInterfaces();
  //他的结构是{key:[{},{}],key:[{},{}]}
  //我们只拿ipv4中的address也就是ip,所以数组扁平化、过滤、过滤
  const address = Object.values(intsrface)
    .flat(1)
    .filter((item) => item.family === "IPv4")
    .map((item) => item.address);
  userConfig["address"] = address;
  return new Server(userConfig);
};

mock.js
  • 这个是自己定义的,如果有mock.js就走mock,没有就不走了
  • 这里也可以根据路径来匹配路由,比如说项目打包后,history路由刷新会找不到文件,可以在这里的get请求出处理下,统一返回某个html
  • 也可mock接口
module.exports = (pathname, req, res) => {
  //如果用户增加了mock.js应该先看是否会命中接口,如果命中就不是静态服务
  if (pathname === "/user") {
    if (req.method === "GET") {
      console.log(req.query);
      res.end("get user");
    } else if (req.method === "POST") {
      console.log(req.query);
      res.end("post user");
    }
    return true;
  }
};

template.html
  • 模板,在server.js里通过ejs进行解析渲染
  • <%开始、%>结束、<%=赋值变量
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>模板</title>
</head>
<body>
   <%dirs.forEach(item=>{%>
        <li><%=item.info%> - <a href="<%=item.url%>"><%=item.dir%></a> - <%=item.size%></li>
    <%})%>
</body>
</html>
nodemon.json
  • 这个没有的话,不影响整体操作,主要是方便调试,因为,每次修改内容后都手动重启服务会比较麻烦
  • npm i nodemon -g
  • 它会自动找nodemon.json,可以在里面设置监听文件,达到自动重启效果
  • 执行的话直接nodemon就可以了
  • 它会找exec命令进行执行
  • watch是监听文件
{
    "restartable": "rs",
    "verbose": true,
    "watch": [
        "public/",
        "src/server.js"
    ],
    "ignore": [],
    "delay": 1000,
    "exec": "DEBUG=development httpserver"
}
index.html
  • 测试文件
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>测试文件</title>
    <link rel="stylesheet" href="./index.css">
</head>

<body>
    <img src="http://127.0.0.1:8080/img.png" alt="">
</body>

</html>
<script>
    const xhr = new XMLHttpRequest();
    // xhr.open('GET', 'http://localhost:8080/user?a=1&b=2', true);
    // xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
    // xhr.responseType = 'json';
    // xhr.onload = function () {
    //     console.log(xhr.response);
    // }
    // xhr.send();


    xhr.open('POST', 'http://localhost:8080/user', true);
    xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
    xhr.setRequestHeader('authorization', '1212')
    xhr.responseType = 'json';
    xhr.onload = function () {
        console.log(xhr.response);
    }
    xhr.send('a=1&b=2');
</script>

最后一步

  • npm link 建立软连接

使用

  • httpserver 将当前目录当做静态资源
  • httpserver -h 查看使用提示
  • httpserver -d 路径 指定路径为静态资源目录
  • httpserver -p 端口 指定端口

逻辑梳理

代码调试

  • 自动,使用nodemon
  • 代码提示在这里使用的两个第三方包debug和chalk
  • chalk可以自定义console.log的颜色
  • debug根据传入的参数生成一个可执行log,在传入环境变量的时候才会执行log,如果发包的话,那么用户执行就不会打印结果,我们也不用去手动删除方法了
  • 在我们nodemon配置可以找到变量 DEBUG=development
  • 按理说设置变量window是set,mac是export,它这个啥也不用......

启动服务

  • 通过os模块拿到ip
  • 过滤出ipv4类的ip,然后new Server的时候将地址和默认数据传递过去
  • constructor对数据进行了一个初始化,然后调用start启动服务
  • 这里面做了一个端口字段++逻辑,防止端口被占用导致失败
  • 请求处理在handleRequest中,抽离出来了

跨域

  • 跨域场景
    • 协议域名端口号不一致
    • 在响应头里添加个Access-Control-Allow-Origin请求头的origin即可,跨域的话浏览器会提示你这么做

OPTIONS请求

  • 预检请求
  • 获取服务器的反馈,如支持的HTTP版本号、支持的请求方法、支持的自定义请求头、是否支持跨域等
  • 简单请求不会发送OPTIONS,只有跨域或复杂请求才会有
    • 简单请求:GET、POST
    • 复杂请求:DELETE、PUT、添加了自定义头的GET或POST
    • 设置允许请求的类型:Access-Control-Allow-Methods
    • 设置允许的请求头:Access-Control-Allow-Headers,如果options通过了了,但自定义请求头没设置允许,那还是跨域
    • 每次都发options看着太多了,可以设置缓存,让客户端一段时间内不发,以秒为单位Access-Control-Max-Age
  • 这块逻辑可以拆出来,其他地方复用
  cors(req, res) {
    if (req.headers.origin) {
      res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
      res.setHeader("Access-Control-Allow-Headers", "authorization"); //因为加了自定义header,所以服务端也要支持这个自定义头
      res.setHeader("Access-Control-Max-Age", 10);
      res.setHeader(
        "Access-Control-Allow-Methods",
        "GET,POST,DELETE,PUT,OPTIONS"
      );
      //如果是复杂请求,会发OPTIONS请求,不处理的话,会卡住,长时间pending状态
      //那么我们让他直接通过
      if (req.method === "OPTIONS") {
        res.end();
        return true;
      }
    }
  }

mock数据

  • 这里需要注意一下,post需要通过ondata与onend来获取,因为是流传播,不是一次性拿到的 而get参数是query可以直接拿到的,这个query是通过parse处理过的
  • 通过existsSync来获取mock.js是否存在,不存在继续走后面逻辑

静态资源处理

  • 因为路径对应文件可能不存在,所以try catch包一层,不存在直接返回Not fount
  • 存在的话就判断是文件还是文件夹,
    • 如果是文件直接返回
    • 是文件夹就找它的index.html
      • 存在就返回html
      • 不存在就拿到它的目录数据,然后通过ejs搭配模板渲染出列表,可以点击跳转

文件的返回

  • 这里默认使用流的方式,缓存、压缩、防盗链放下面分开说

防盗链

  • 在a的域名下有一些图片,而b看到了,想省事,那么它直接在自己的html里通过url访问a的图片,那a不希望b使用,就需要阻止
  • req的header会待过来一个referer参数,它代表的是请求发出的地址,告诉服务器我是从那个页面链接过来的
    • referer是早期Http搞错了,后续改成referrer了,但仍然支持referer
    • 直接访问图片,不会有这个字段
    • 我们可以通过对referer的限制来达到防盗效果,例如返回Not found或固定的警告图片
    if (/\.(jpeg|png|jpg)$/.test(filename)) {
      //如果是图片
      let referer = req.headers["referer"] || req.headers["referrer"];
      if (referer) {
        let host = "http://" + req.headers["host"];
        let r1 = url.parse(host).hostname;
        let r2 = url.parse(referer).hostname;
        if (r1 != r2) {
          //不一样,不给你返
          res.end("Not found");
          return;
        }
      }
    }

压缩

  • 常见的三种格式gzip, deflate, br
  • 通过Accept-Encoding拿到浏览器支持的压缩方式,然后通过zlib库创建对应的转化流,在pipe返回的时候,嵌套一层,放在中间即可
  • 压缩后体积会减小,可以在控制台鼠标悬浮size看到效果
  • 记得设置对应的Content-Encoding告诉浏览器解压格式,否则会乱码

常见面试题:为什么我请求一个页面,直接下载了?

  • Content-Type设成压缩格式了
  compress(filename, req, res) {
    //常见的三种格式gzip, deflate, br
    let encoding = req.headers["accept-encoding"]; //拿到浏览器支持的压缩方式
    if (encoding) {
      if (encoding.includes("br")) {
        //是否支持br
        res.setHeader("Content-Encoding", "br");
        return zlib.createBrotliCompress();
      } else if (encoding.includes("gzip")) {
        res.setHeader("Content-Encoding", "gzip");
        return zlib.createGzip();
      } else if (encoding.includes("deflate")) {
        res.setHeader("Content-Encoding", "deflate");
        return zlib.createDeflate();
      }
    }
  }

缓存

  • 走缓存顺序:先走强缓存,再走协商缓存
  • 强缓存:缓存期间不向服务器发请求
  • 协商缓存:拿着对应的头,向服务器发送请求,如果走缓存,服务器返回304,浏览器自己本地找缓存
  • 有一些淘汰方案,代码有点多,贴server.js代码里了