尝试手写一个 nodejs http-server(含发布到npm的流程)

6,716 阅读9分钟

前言: 关于 http server

应该有小伙伴了解或用过http-serverhttp-server是一个node环境下的命令行http服务器,这里是npm官网的链接 www.npmjs.com/package/htt… , 可以从npm的官网查到其用法:
即npm安装后,在命令行输入指令http-server直接开启服务器,在服务器启动的目录下,默认会找public静态资源目录,去访问默认的127.0.0.1:8080 可以访问到静态目录站点。

非常易用和方便,如果我们想改变端口,默认目录,或者主机名等等,可以在启动时在命令行直接配置

具体用法就是cmd: http-server -p 3001 那么启动时就会访问3001端口,其他配置可以参考npm官网了解,在这里就不赘述了。本篇主要想通过http-server的底层原理,实现一个简易的http-server包,实现后还可以发到npm上成为自己的作品哦,一起看看吧。

npm 注册

想要了解发布npm的同学可以在npm官网注册属于自己的npm账号,注意邮箱一定要验证哦,不然发布不了自己的包

开始项目前最好提前了解的一些内容

  • nodejs环境
  • fs模块,包括读取文件,读写流等
  • async,promise
  • http知识
  • ejs模板引擎

一 项目搭建

package.json

首先要安装node环境,因为我们的项目是基于nodejs的http工具。 创建项目文件夹后用npm或yarn初始化都可以,创建项目的package.json文件,配置过程跟随包管理工具的提示即可,输入name的时候,可以使用你想要发布的包的名字;author输入你在npm官网注册的用户名,版本就默认是1.0.0就好,我的pachage.json配置如下

这里bin我们配置为 启动命令行自定义名:"bin/www.js",作为我们的命令行启动配置文件
main: index.js是我们主要逻辑脚本
author是作者名,这里使用npm官网的用户名保持一致即可

文件夹结构

建议大家这样配置 首先有bin文件夹,下放www.js,这里我们用于配置命令行工具,也是启动包的关键所在
node_modules为安装依赖后生成的,请忽略
public是我们想要让用户访问的静态资源目录,可以随意放一些文件夹和文件
src是我们的核心js和配置
src/template.html是展示的界面,因为我们要搭建的是一个静态资源站点,用户需要访问页面,可以点击目录或文件等操作。

二 核心代码

1. src/config.js

首先我们构建config.js,即默认配置项

module.exports = {
    port: 8080, // 默认端口
    host: 'localhost', // 默认主机名
    dir: process.cwd() // 默认读取目录
}

我们导出默认的端口号,默认的主机名,和默认的文件目录,
其中 process.cwd()是读取进程当前的工作目录,你在哪启动,就读哪个目录

2. src/template.html

上面讲到了template.html用于展示目录,我们这里采用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>Document</title>
</head>
<body>
    <h2><%=name%></h2>
    <%arr.forEach(item=>{%>
      <li><a href="<%=item.href%>"><%=item.name%></a></li>
    <%})%>
</body>
</html>

h2标签中我们展示当前路径 li为文件目录结构,可供点击进入文件或文件夹;可以看出,我们要给这个页面输出name和arr两个数据,在核心js中会详细讲如何渲染template.

3. src/index.js

这里是我们的主要逻辑脚本了,本js里会封装Server类,用于在www.js中启动服务器
简要架构:

我们引入的模块主要有:

  • http http模块
  • util util工具模块,主要使用promisify方法
  • url url模块 可方便获取路径
  • zlib 文件压缩模块,用于创建gzip或deflate的转化流
  • fs 文件系统模块
  • path 路径拼接
  • querystring 路径处理
  • ejs 模板引擎
  • chalk 粉笔模块 可以给输出的命令行文本加颜色喔
  • mime 获取文件类型
  • debug debug模块
  • config 我们自己写的配置文件

下面开始构建 Server 类,用于处理请求中的各种情况,返回不同的内容

① 构造Server

class Server {
    constructor(command) {
        this.config = {...config,...command} // config和命令行的内容展示
        this.template = template;
    }
}

② 主要的请求处理方法 ※

class Server {
    ...
    async handleRequest(req, res) {
        let { dir } = this.config; // 需要将请求的路径和dir拼接在一起
        //如 http://localhost:8080/index.html
        let { pathname } = url.parse(req.url);
        // 如果独到的是网站小图标,就直接输出
        if (pathname === '/favicon.ico') return res.end();
        pathname = decodeURIComponent(pathname); // 对文件夹名称进行转码处理
        // p是决定文件路径
        let p = path.join(dir, pathname);
        try {
          // 判断当前路径是文件 还是文件夹
          let statObj = await stat(p);
          if (statObj.isDirectory()) {
            // 读取当前访问的目录下的所有内容 readdir 数组 把数组渲染回页面
            res.setHeader('Content-Type', 'text/html;charset=utf8')
            let dirs = await readdir(p);
            dirs = dirs.map(item=>({
              name:item,
              // 因为点击第二层时 需要带上第一层的路径,所有拼接上就ok了
              href:path.join(pathname,item)
            }))
            // 渲染template.html中需要填充的内容,name是当前文件目录,arr为当前文件夹下的目录数组
            let str = ejs.render(this.template, {
              name: `Index of ${pathname}`,
              arr: dirs
            });
            
            // 响应中返回填充内容
            res.end(str);
        
          } else {
          // 如果不是文件夹,则直接输出文件内容
            this.sendFile(req, res, statObj, p);
          }
        } catch (e) {
          debug(e); // 发送错误
          this.sendError(req, res);
        }
  }
    ...
}

③ 处理用户缓存

用于告知服务器是否本次缓存,如果是浏览器客户端已经缓存的文件,直接读取缓存即可,优化性能

class Server {
    ...
    
    cache(req, res, statObj, p ) {
        // 设置缓存头
        res.setHeader('Cache-Control', 'no-cache');
        res.setHeader('Expires', new Date(Date.now() + 10 * 1000).getTime());
        // 设置etag和上次最新修改时间
        let eTag = statObj.ctime.getTime() + '-' + statObj.size;
        let lastModified = statObj.ctime.getTime();
        // 传给客户端
        res.setHeader('Etag', eTag);
        res.setHeader('Last-Modified', lastModified);
        // 客户端把上次设置的带过来
        let ifNoneMatch = req.headers['if-none-match'];
        let ifModifiedSince = req.headers['if-modified-since'];
        // 其中任意一个不生效缓存就不生效
        if (eTag !== ifNoneMatch && lastModified !== ifModifiedSince) {
            return false;
        }

        return true;
    }
    
    ...
}

④ 是否压缩

返回压缩文件,优化访问速度

class Server {
    ...

     // 是否压缩
    gzip(req, res, statObj, p) {
        // 判断请求头是否设置了接收编码
        let encoding = req.headers['accept-encoding'];
        // 如果有则判断是否有gzip或者deflate
        if (encoding) {
            // gzip
            if (encoding.match(/\bgzip\b/)) {
                res.setHeader('Content-Encoding', 'gzip');
                return zlib.createGzip();
            }
            // deflate
            if (encoding.match(/\bdeflate\b/)) {
                res.setHeader('Content-Encoding', 'deflate');
                return zlib.createDeflate();
            }
            return false;
        }
        else {
            return false;
        }
    }
    
    ...
}

⑤ 判断是否有范围请求

判断是否请求头

class Server {
    ...
    range(req, res, statObj, p) {
        let range = req.headers['range'];
        // 有范围请求时返回读流,断点续传
        if (range) {
            let [, start, end] = range.match(/bytes=(\d*)-(\d*)/);
            start = start ? Number(start) : 0;
            end = end ? Number(end) : statObj.size - 1;
            res.statusCode = 206;
            res.setHeader('Content-Range', `bytes ${start}-${end}/${statObj.size - 1}`);
            fs.createReadStream(p, {start, end}).pipe(res);
        }
        else {
            return false;
        }
    }
    ...
    
}

⑥ 发送文件

发送文件方法,即我们在 handleRequest时如果判断走到认为读取的是一个文件,则发送这个文件展示给用户

class Server {
    ...
     sendFile(req, res, statObj, p) {
        if (this.cache(req, res, statObj, p)) {
            res.statusCode = 304;
            return res.end();
        }
        // 是范围请求就忽略
        if (this.range(req, res, statObj, p)) return;
        // 设置文件类型头,如果不设置,我们访问一个html文件可能会导致下载
        res.setHeader('Content-Type', mime.getType(p) + ';charset=utf8');
        // 如果是需要压缩则定义gzip转化流,讲文件压缩后输出
        let transform = this.gzip(req, res, statObj, p);
        if (transform) {
            return fs.createReadStream(p).pipe(transform).pipe(res);
        }
        // 如果不是不需要压缩则直接返回文件
        fs.createReadStream(p).pipe(res);
    }
    ...
    
}

⑦ 处理报错

在handleRequest方法中的处理错误方法,

class Server {
    ...
   sendError(req, res){
    // 返回的状态码设置为404
        res.statusCode = 404;
        // 页面返回文字
        res.end(`404 Not Found`);
        this.start();
    }
    ...
    
}

⑧ 启动方法

原生http模块的创建服务器方法,创建成功后再cmd界面输出,告知主机和端口

class Server {
    ...
   start() {
        let server = http.createServer(this.handleRequest.bind(this));
        server.listen(this.config.port, this.config.host, ()=> {
            console.log(`server start http://${this.config.host}:${chalk.green(this.config.port)}`);
        });
    }
    ...
    
}

至此,我们的核心代码已经写完了,简易处理了服务器端需要处理的一些状况,有兴趣的同学可以补充和完善

三 bin/www.js 执行脚本

我们在命令行中输入的指令,调器执行脚本,并开启服务器
注意在www.js开头一定要写 #! /usr/bin/env node 告知操作系统node环境执行,以下为www.js的内容

#! /usr/bin/env node
let Server = require('../src/index.js'); // 导入Server
let commander = require('commander'); // 导入命令行模块
let {version} = require('../package.json'); // 读取package.json的版本

// 配置命令行
commander
.option('-p,--port <n>', 'config port') // 配置端口
.option('-o,--host [value]', 'config hostname') // 配置主机名
.option('-d,--dir [value]', 'config directory') // 配置访问目录
.version(version, '-v,--version').parse(process.argv); // 展示版本

let server = new Server(commander);
server.start(); // 启动

let config =require('../src/config');

commander = {...config, ...commander}

let os = require('os');
// 执行模块
let {exec} = require('child_process')

// 判断操作系统平台,win32是windows,执行访问程序,会自动弹出默认浏览器喔
if (os.platform() === 'win32') {
    exec(`start http://${commander.host}:${commander.port}`);
}
else {
    exec(`open http://${commander.host}:${commander.port}`);
}

再看一下我们在package.json中的配置

"bin": {
    "zyx-http-server": "bin/www.js"
  },

即我们的启动命令是 zyx-http-server,也可以根据自己的配置,补充其他命令,如 zyx-http-server -d public,则读取public作为静态资源根目录

npm link

处理npm install 我们包中的依赖(ejs, chalk, debug等)之外还需要执行
npm link:将一个任意位置的npm包链接到全局执行环境,从而在任意位置使用命令行都可以直接运行该npm包

四 运行

在文件夹启动命令行工具 执行zyx-http-server -d public,没有什么问题的话,我们会弹出

浏览器并访问public目录
点击a文件夹

打开文件

源文件:

至此,我们实现了传说中的http-server静态服务器,由于我叫zyx啦,所以取名叫zyx-http-server

五 发布到npm官网

没有注册的同学看到这一步的话请先去npm官网注册一个属于自己的账号,然后我们才能发布到自己包。
进行之前有这么几点需要注意

  • 切换源到npm节点,如果平时使用cnpm或者其他节点的同学,请在命令行输入nrm use npm切换
  • 在npm的注册邮箱一定要验证才可以,官网会发一份验证邮件给你,点击进行验证;我遇到了一种情况是验证过了,但是没生效,这时候你去官网再改一次邮箱试试
  • 要发的包的命名,是package.json中的name配置项,一开始没有配置的,可以去写一个自己的包名,可以通过访问 www.npmjs.com/package/ + 你的包名,看看在npm有没有被占用,被占用的话就换一个哦,一般被占用的话,你也无法提交

npm login

需要在命令行执行 npm login登录npm,如果你一开始没有切换到npm官网节点,cnpm用npm账号也是可以登录的,所以请提前先切换到npm

注意密码输入的时候是看不见的,我一开始也不知道,实际是输入对的 _ _(:з」∠)__,邮箱必须是你的认证邮箱

npm publish

登录成功后,就可以执行
npm publish指令,发布成功有如下提示

这时候就可以去官网看一下你的包是不是成功了。
更新代码的流程和发布是一样的,但是你要更新package.json中的version号。 这是我的包的地址,大家有兴趣可以看看 zyx-http-server

使用自己的包

依然是使用 npm i zyx-http-server -g 全局安装或局部安装这个包,我们试一下:

可以启动

中文文件夹bug已修复

在handleRequest方法中对pathname进行转码处理


希望我的文章可以帮到你~