乐趣区

关于前端:作为前端工程师你还不懂怎么用Node写静态文件服务器吗

背景

作为前端工程师,我想大家肯定对 动态文件服务器 不会生疏。所谓的动态文件服务器做的工作就是将咱们的 前端动态文件 (.js/.css/.html) 传输给浏览器,而后浏览器再将咱们的页面渲染进去。咱们罕用的 webpack-dev-server 就是本地开发用的动态文件服务器,而个别线上环境咱们会应用 nginx,因为它更加稳固和高效。既然动态文件服务器无处不在,那么它们又是如何实现的呢?本篇文章将带你手把手实现一个 高效的动态文件服务器

性能介绍

咱们的动态服务器包含上面两个性能:

  • 当用户申请的内容是 文件夹 时,展现以后 文件夹的构造信息
  • 当用户申请的内容是 文件 时,返回 文件的内容

咱们来看一下实际效果,服务端的动态文件目录是这样的:

static
└── index.html

拜访 localhost:8080 能够获取 根目录 的信息:

在根目录下只有一个 index.html 文件。咱们点击 index.html 文件能够获取这个文件的具体内容:

代码实现

依据下面的需要形容,咱们先用 流程图 来设计一下咱们的逻辑如何实现:

其实动态文件服务器的实现思路还是很简略的:先判断资源存不存在 ,不存在就间接报错,资源存在的话 依据资源的类型返回对应的后果给客户端 就能够了。

根底代码实现

看完下面的 流程图,我置信大家的思路根本清晰了,接着咱们看一下具体的代码实现:

const http = require('http')
const url = require('url')
const fs = require('fs')
const path = require('path')
const process = require('process')

// 获取服务端的工作目录,也就是代码运行的目录
const ROOT_DIR = process.cwd()

const server = http.createServer(async (req, resp) => {const parsedUrl = url.parse(req.url)
  // 删除结尾的 '/' 来获取资源的相对路径,e.g: `/static` 变为 `static`
  const parsedPathname = parsedUrl.pathname.slice(1)
  // 获取资源在服务端的绝对路径
  const pathname = path.resolve(ROOT_DIR, parsedPathname)

  try {
    // 读取资源的信息, fs.Stats 对象
    const stat = await fs.promises.stat(pathname)

    if (stat.isFile()) {
      // 如果申请的资源是文件就交给 sendFile 函数解决
      sendFile(resp, pathname)
    } else {
      // 如果申请的资源是文件夹就交给 sendDirectory 函数解决
      sendDirectory(resp, pathname)
    }
  } catch (error) {
    // 拜访的资源不存在
    if (error.code === 'ENOENT') {
      resp.statusCode = 404
      resp.end('file/directory does not exist')
    } else {
      resp.statusCode = 500
      resp.end('something wrong with the server')
    }
  }
})

server.listen(8080, () => {console.log('server is up and running')
})

在下面的代码中我应用 http 模块创立了一个 server 实例,这个实例外面定义了解决所有 HTTP 申请的 handler 函数。handler函数实现比较简单,读者依据下面的代码正文就可以看明确了,这里想要阐明一下我为什么应用 fs.promises.stat 来获取资源的元信息 (fs.Stats 类,包含资源的类型和更改工夫等)而不应用能够实现同一个性能的 fs.statfs.statSync:

  • fs.promises.stat vs fs.stat: fs.promises.statpromise-style 的,能够应用 asyncawait来实现异步的逻辑,代码很洁净。而 fs.statcallback-style的,这种 API 写异步逻辑最初可能会变成 意大利面条,前期保护艰难。
  • fs.promises.stat vs fs.statSync: fs.promises.stat读取文件的信息是一个 异步操作 ,不会阻塞主线程的执行。而fs.statSync 是同步的,这也就意味着当这个 API 执行的时候,JS主线程会卡死,其它的资源申请是解决不了的。这里我也倡议当大家须要在服务端 进行文件系统的读写 的时候,肯定要 优先应用异步 API 防止应用同步式的 API

接着咱们来看一下 sendFilesendDirectory这两个函数的具体实现:

const sendFile = async (resp, pathname) => {
  // 应用 promise-style 的 readFile API 异步读取文件的数据,而后返回给客户端
  const data = await fs.promises.readFile(pathname)
  resp.end(data)
}

const sendDirectory = async (resp, pathname) => {
  // 应用 promise-style 的 readdir API 异步读取文件夹的目录信息,而后返回给客户端
  const fileList = await fs.promises.readdir(pathname, { withFileTypes: true})
  // 这里保留一下子资源绝对于根目录的相对路径,用于前面客户端持续拜访子资源
  const relativePath = path.relative(ROOT_DIR, pathname)

  // 结构返回的 html 构造体
  let content = '<ul>'
  fileList.forEach(file => {
    content += `
    <li>
      <a href=${relativePath}/${file.name}>${file.name}${file.isDirectory() ? '/' : ''}
      </a>
    </li>` 
  })

  content += '</ul>'
  // 返回以后的目录构造给客户端
  resp.end(`<h1>Content of ${relativePath || 'root directory'}:</h1>${content}`)
}

sendDirectory通过 fs.promises.readdir 来获取其底下的目录信息,而后以 列表 的模式返回一个 html 构造给客户端。这里值得一提的是:因为客户端须要依照返回的子资源信息进一步拜访子资源,所以咱们须要记录子资源 绝对于根目录的相对路径 sendFile 函数的实现绝对于 sendDirectory 会简略一点,它只须要读取文件的内容而后返回给客户端就能够了。

下面的代码写完后,咱们其实曾经实现了下面说的需要了,可是这个服务端是 生产不可用的,因为它有很多潜在的问题没有解决,接着就让咱们看一下如何解决这些问题来优化咱们的服务端代码。

大文件优化

咱们先来看看在当初的实现下,客户端申请一个大文件会产生什么。首先咱们在 static 文件夹下筹备一个大文件test.txt,这个文件外面有 1000 万行Hello World!,文件的大小为124M:

而后咱们启动服务器,查看服务器启动实现后 Node 的 内存占用状况:

能够看到 Node 服务只占用了 8.5M 的内存,咱们在浏览器拜访一下test.txt:

浏览器在疯狂输入Hello World!,这个时候再看一眼 Node 的内存占用状况:

内存应用一下子由 8.5M 激增到了 132.9M,而减少的资源差不多就是文件的大小124M,这到底是为什么呢?咱们再来看一下sendFile 文件的实现:

const sendFile = async (resp, pathname) => {
  // readFile 会读取文件的数据而后存在 data 变量外面
  const data = await fs.promises.readFile(pathname)
  resp.end(data)
}

下面的代码中,其实咱们会一次性读取文件的内容而后保留在 data 变量外面,也就是说咱们会将 124M 的文本信息保留在 内存外面 !你试想一下,如果有多个用户同时拜访大资源,咱们的程序必定会因为内存爆炸而OOM(Out of Memory) 的。那么这个问题如何解决呢?其实 node 提供的 stream 模块能够很好地解决咱们的问题。

Stream

咱们先来看一下 stream 的官网介绍:

A stream is an abstract interface for working with streaming data in Node.js.
There are many stream objects provided by Node.js. For instance, a request to an HTTP server and process.stdoutare both stream instances.Streams can be readable, writable, or both. All streams are instances of EventEmitter

简略来说,stream就是给咱们 流式解决 数据用的,那么什么是 流式解决 呢?用最简略的话来说就是:不是 一下子解决完数据 而是 一点一点地解决 它们。应用 stream,咱们要解决的数据就会一点一点地加载到内存的某一个固定大小的区域(buffer) 以给其它消费者生产。因为保留数据的 buffer 大小个别是固定的,当旧的数据处理完才会加载新的数据,因而它能够防止内存的解体。话不多说,咱们马上应用 stream 来重构一下下面的 sendFile 函数:

const sendFile = async (resp, pathname) => {
  // 为须要读取的文件创建一个可读流 readableStream
  const fileStream = fs.createReadStream(pathname)
  fileStream.pipe(resp)
}

下面的代码中,咱们为须要读取的文件创建了一个 可读流 (ReadableStream),而后将这个流和resp 对象连接 (pipe) 在一起,这样文件的数据就会源源不断发送给客户端了。看到这里你可能会问,为什么 resp 对象能够和 fileStream 连贯在一起呢?起因就是这个 resp 对象底层是一个 可写流 (WritableStream),而可读流的pipe 函数接管的就是 可写流 。优化完后咱们再来申请一下test.txt 大文件,同样浏览器一顿疯狂输入,不过这个时候 Node 服务的内存用量是这样的:

Node 的内存根本稳固在 9.0M,比服务刚启动时只多了0.5M!从这个能够看出咱们通过stream 来优化的确达到了很好的成果。因为文章篇幅的限度,这里没有具体介绍 stream 的 API 如何应用,须要理解的同学能够自行查看官网文档。

缩小文件传输带宽

应用 stream 确实能够缩小服务端的 内存占用问题 ,可是它 没有缩小服务端和客户端传输的数据大小 。换句话来说,如果咱们的文件大小是2M 咱们就实打实传输这 2M 的数据给客户端。如果客户端是手机或者其它挪动设施的话,这么大的带宽耗费必定是不可取的。这个时候咱们须要对被传输的数据进行 压缩 而后再在客户端进行解压,这样传输的数据量能力大幅度缩小。服务端数据压缩的算法有很多,这里我应用了一个比拟罕用的 gzip 算法,咱们来看一下如何更改 sendFile 以反对数据压缩:

// 引入 zlib 包
const zlib = require('zlib')

const sendFile = async (resp, pathname) => {
  // 通过 header 通知客户端:服务端应用的是 gzip 压缩算法
  resp.setHeader('Content-Encoding', 'gzip')
  // 创立一个可读流
  const fileStream = fs.createReadStream(pathname)
  // 文件流首先通过 zip 解决再发送给 resp 对象
  fileStream.pipe(zlib.createGzip()).pipe(resp)
}

在下面的代码中,我应用 Node 原生的 zlib 模块创立了一个 转换流 (Transform Stream),这种流是 既可读又可写的 (Readable and Writable Stream),所以它像是一个 转换器 将输出的数据进行加工而后输入到上游的可写流。咱们申请 index.html 文件来看一下优化后的成果:

上图中,第一行的申请是没有通过 gzip 压缩的申请大小,大略是 2.6kB,而通过gzip 压缩后传输数据一下子变成373B,优化成果非常显著!

应用浏览器缓存

数据压缩 尽管解决了服务端客户端传输数据的带宽问题,可是没有解决 反复数据传输的问题 。咱们晓得一般来说服务器的动态文件是很少会扭转的,在服务端资源没有产生扭转的前提下,同一个客户端屡次拜访同一个资源, 服务端会传输一样的数据 ,而这种状况下更无效的形式是:服务器通知客户端资源没有变动,你间接应用缓存就能够了。浏览器缓存的形式有很多种,有 协商缓存 强缓存 。对于这两种缓存的区别我想网络上曾经有很多文章说得很清晰了,我在这里也不再多说,本篇文章次要想说一下 强缓存 Etag机制如何实现。

什么是 Etag

其实 Etag(Entity-Tag)能够了解为文件内容的 指纹 ,如果文件内容产生了扭转那么这个指纹是 大概率 是会变的。这里留神的是我用了大概率而不是相对,这是因为 HTTP1.1 协定外面并没有规定 etag 具体生成算法是什么,这齐全是由开发者本人决定的。通常对于文件来说,etag是由 文件的长度 + 更改工夫 生成的,这种做法其实是会存在浏览器读取不到最新文件内容的状况的,不过这不是本文的重点,有趣味的同学能够参考网上的其它材料。

接着让咱们图解一下基于 etag协商缓存 过程:

具体的过程如下:

  • 浏览器第一次申请服务端的资源时,服务端会在 Response 外面设置以后资源的 etag 信息,例如Etag: 5d-1834e3b6ea2
  • 浏览器第二次申请服务端资源时,会在申请头部的 If-None-Match 字段带上最新的 etag 信息 5d-1834e3b6ea2。服务端收到申请解析出If-None-Match 字段并将其和最新的服务端 etag 进行比照,如果是一样的就会返回 304 给浏览器示意资源无更新,如果资源产生了更改则将最新的 etag 设置到头部并且将最新的资源返回给浏览器。

接着咱们来看一下 sendFile 函数如何反对etag:

// 这个函数会依据文件的 fs.Stats 信息计算出 etag
const calculateEtag = (stat) => {
  // 文件的大小
  const fileLength = stat.size
  // 文件的最初更改工夫
  const fileLastModifiedTime = stat.mtime.getTime()
  // 数字都用 16 进制示意
  return `${fileLength.toString(16)}-${fileLastModifiedTime.toString(16)}`
}

const sendFile = async (req, resp, stat, pathname) => {
  // 文件的最新 etag
  const latestEtag = calculateEtag(stat)
  // 客户端的 etag
  const clientEtag = req.headers['if-none-match']
  
  // 客户端能够应用缓存
  if (latestEtag == clientEtag) {
    resp.statusCode = 304
    resp.end()
    return
  }

  resp.statusCode = 200
  resp.setHeader('etag', latestEtag)
  resp.setHeader('Content-Encoding', 'gzip')
  
  const fileStream = fs.createReadStream(pathname)
  fileStream.pipe(zlib.createGzip()).pipe(resp)
 }

在下面的代码中我新增了一个计算 etag 的函数 calculateEtag,这个函数会依据文件的大小和最初更改工夫算出文件最新的etag 信息。接着我还批改了 sendFile 的函数签名,接管了 req(HTTP 申请体) 和stat(文件的信息,fs.Stats 类)两个新参数。sendFile会先判断客户端的 etag 和服务端的 etag 是不是一样的,如果雷同就返回 304 给客户端否则返回文件的最新内容并且在 header 设置最新的 etag 信息。同样咱们再次拜访 index.html 文件来验证优化成果:

上图能够看到第一次申请资源时浏览器没有缓存,服务端返回了文件的最新内容和 200 状态码,这个申请的理论带宽是 396B,第二次申请时,因为 浏览器有缓存并且服务端资源没有更新 ,所以服务端返回304 状态码而没有返回理论的文件内容,这个时候的文件理论带宽是 113B!能够看出优化成果是很显著的,咱们略微更改一下index.html 的内容来验证一下客户端会不会拉到最新的数据:

从上图能够看出当 index.html 更新后,旧的 etag 生效,浏览器能够获取最新的数据。咱们最初再来看一下这三个申请的详细信息,上面是第一次申请时,服务端给浏览器返回 etag 信息:

接着是第二次申请时,客户端申请服务端资源时带上 etag 信息:

第三次申请,etag生效,拿到新的数据:

值得一提的是,这里咱们只通过 etag 实现了浏览器的缓存,这是不齐备的,理论的动态服务器可能会加上基于 Expires/Cache-Control强缓存 和基于 Last-Modified/Last-Modified-Since协商缓存 来优化。

总结

本篇文章我先实现了一个最简略能用的 动态文件服务器 ,而后通过解决三个理论应用时会遇到的问题优化了咱们的代码,最初实现了一个 简略高效的动态文件服务器

如上文所说,因为篇幅的限度,咱们的实现上还是漏了很多货色的,例如 MIME 类型的设置,反对更多的压缩算法如 deflate 以及反对更多的缓存形式如 Last-Modified/Last-Modified-Since 等。这些内容其实在把握了下面的办法后很容易就能够实现了,所以就留给大家在须要真正用到的时候本人实现了。

集体技术动静

创作不易,如果你从这篇文章中学到货色,请给我点一下赞或者关注,你的反对是我持续创作的最大能源!

同时欢送关注公众号 进击的大葱 一起学习成长

退出移动版