乐趣区

关于前端:手写koastatic源码深入理解静态服务器原理

这篇文章持续后面的 Koa 源码系列,这个系列曾经有两篇文章了:

  1. 第一篇解说了 Koa 的外围架构和源码:手写 Koa.js 源码
  2. 第二篇解说了 @koa/router 的架构和源码:手写 @koa/router 源码

本文会接着讲一个罕用的中间件 —-koa-static,这个中间件是用来搭建动态服务器的。

其实在我之前应用 Node.js 原生 API 写一个 web 服务器曾经讲过怎么返回一个动态文件了,代码尽管比拟丑,根本流程还是差不多的:

  1. 通过申请门路取出正确的文件地址
  2. 通过地址获取对应的文件
  3. 应用 Node.js 的 API 返回对应的文件,并设置相应的header

koa-static的代码更通用,更优雅,而且对大文件有更好的反对,上面咱们来看看他是怎么做的吧。本文还是采纳一贯套路,先看一下他的根本用法,而后从根本用法动手去读源码,并手写一个简化版的源码来替换他。

根本用法

koa-static应用很简略,次要代码就一行:

const Koa = require('koa');
const serve = require('koa-static');

const app = new Koa();

// 次要就是这行代码
app.use(serve('public'));

app.listen(3001, () => {console.log('listening on port 3001');
});

上述代码中的 serve 就是 koa-static,他运行后会返回一个Koa 中间件,而后 Koa 的实例间接援用这个中间件就行了。

serve办法反对两个参数,第一个是动态文件的目录,第二个参数是一些配置项,能够不传。像下面的代码 serve('public') 就示意 public 文件夹上面的文件都能够被内部拜访。比方我在外面放了一张图片:

跑起来就是这样子:

留神下面这个门路申请的是 /test.jpg,后面并没有public,阐明koa-static 对申请门路进行了判断,发现是文件就映射到服务器的 public 目录上面,这样能够避免内部使用者探知服务器目录构造。

手写源码

返回的是一个 Koa 中间件

咱们看到 koa-static 导出的是一个办法 serve,这个办法运行后返回的应该是一个Koa 中间件,这样 Koa 能力援用他,所以咱们先来写一下这个构造吧:

module.exports = serve;   // 导出的是 serve 办法

// serve 承受两个参数
// 第一个参数是门路地址
// 第二个是配置选项
function serve(root, opts) {
    // 返回一个办法,这个办法合乎 koa 中间件的定义
    return async function serve(ctx, next) {await next();
    }
}

调用 koa-send 返回文件

当初这个中间件是空的,其实他应该做的是将文件返回,返回文件的性能也被独自抽取进去成了一个库 —-koa-send,咱们前面会看他源码,这里先间接用吧。

function serve(root, opts) {
    // 这行代码如果成果就是
    // 如果没传 opts,opts 就是空对象{}
    // 同时将它的原型置为 null
    opts = Object.assign(Object.create(null), opts);

    // 将 root 解析为一个非法门路,并放到 opts 下来
    // 因为 koa-send 接管的门路是在 opts 上
    opts.root = resolve(root);
  
      // 这个是用来兼容文件夹的,如果申请门路是一个文件夹,默认去取 index
    // 如果用户没有配置 index,默认 index 就是 index.html
    if (opts.index !== false) opts.index = opts.index || 'index.html';

      // 整个 serve 办法的返回值是一个 koa 中间件
      // 合乎 koa 中间件的范式:(ctx, next) => {}
    return async function serve(ctx, next) {
        let done = false;    // 这个变量标记文件是否胜利返回

        // 只有 HEAD 和 GET 申请才响应
        if (ctx.method === 'HEAD' || ctx.method === 'GET') {
            try {
                // 调用 koa-send 发送文件
                // 如果发送胜利,koa-send 会返回门路,赋值给 done
                // done 转换为 bool 值就是 true
                done = await send(ctx, ctx.path, opts);
            } catch (err) {
                // 如果不是 404,可能是一些 400,500 这种非预期的谬误,将它抛出去
                if (err.status !== 404) {throw err}
            }
        }

        // 通过 done 来检测文件是否发送胜利
        // 如果没胜利,就让后续中间件持续解决他
        // 如果胜利了,本次申请就到此为止了
        if (!done) {await next()
        }
    }
}

opt.defer

defer是配置选项 opt 外面的一个可选参数,他略微非凡一点,默认为 false,如果你传了truekoa-static 会让其余中间件先响应,即便其余中间件写在 koa-static 前面也会让他先响应,本人最初响应。要实现这个,其实就是管制调用 next() 的机会。在讲 Koa 源码的文章外面曾经讲过了,调用 next() 其实就是在调用前面的中间件,所以像下面代码那样最初调用 next(),就是先执行koa-static 而后再执行其余中间件。如果你给 defer 传了 true,其实就是先执行next(),而后再执行koa-static 的逻辑,依照这个思路咱们来反对下 defer 吧:

function serve(root, opts) {opts = Object.assign(Object.create(null), opts);

    opts.root = resolve(root);

    // 如果 defer 为 false,就用之前的逻辑,最初调用 next
    if (!opts.defer) {return async function serve(ctx, next) {
            let done = false;    

            if (ctx.method === 'HEAD' || ctx.method === 'GET') {
                try {done = await send(ctx, ctx.path, opts);
                } catch (err) {if (err.status !== 404) {throw err}
                }
            }

            if (!done) {await next()
            }
        }
    }

    // 如果 defer 为 true,先调用 next,而后执行本人的逻辑
    return async function serve(ctx, next) {
        // 先调用 next, 执行前面的中间件
        await next();

        if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return

        // 如果 ctx.body 有值了,或者 status 不是 404,阐明申请曾经被其余中间件解决过了,就间接返回了
        if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line

        // koa-static 本人的逻辑还是一样的,都是调用 koa-send
        try {await send(ctx, ctx.path, opts)
        } catch (err) {if (err.status !== 404) {throw err}
        }
    }
}

koa-static源码总共就几十行:https://github.com/koajs/static/blob/master/index.js

koa-send

下面咱们看到 koa-static 其实是包装的 koa-send,真正发送文件的操作都是在koa-send 外面的。文章最结尾说的几件事件 koa-static 一件也没干,都丢给 koa-send 了,也就是说他应该把这几件事都干完:

  1. 通过申请门路取出正确的文件地址
  2. 通过地址获取对应的文件
  3. 应用 Node.js 的 API 返回对应的文件,并设置相应的header

因为 koa-send 代码也不多,我就间接在代码中写正文了,通过后面的应用,咱们曾经晓得他的应用模式是:

send (ctx, path, opts)

他接管三个参数:

  1. ctx:就是 koa 的那个上下文ctx
  2. pathkoa-static传过来的是 ctx.path,看过koa 源码解析的应该晓得,这个值其实就是req.path
  3. opts: 一些配置项,defer后面讲过了,会影响执行程序,其余还有些缓存管制什么的。

上面间接来写一个 send 办法吧:

const fs = require('fs')
const fsPromises = fs.promises;
const {stat, access} = fsPromises;

const {
    normalize,
    basename,
    extname,
    resolve,
    parse,
    sep
} = require('path')
const resolvePath = require('resolve-path')

// 导出 send 办法
module.exports = send;

// send 办法的实现
async function send(ctx, path, opts = {}) {
    // 先解析配置项
    const root = opts.root ? normalize(resolve(opts.root)) : '';  // 这里的 root 就是咱们配置的动态文件目录,比方 public
    const index = opts.index;    // 申请文件夹时,会去读取这个 index 文件
    const maxage = opts.maxage || opts.maxAge || 0;     // 就是 http 缓存管制 Cache-Control 的那个 maxage
    const immutable = opts.immutable || false;   // 也是 Cache-Control 缓存管制的
    const format = opts.format !== false;   // format 默认是 true,用来反对 /directory 这种不带 / 的文件夹申请

    const trailingSlash = path[path.length - 1] === '/';    // 看看 path 结尾是不是 /
    path = path.substr(parse(path).root.length)             // 去掉 path 结尾的 /

    path = decode(path);      // 其实就是 decodeURIComponent,decode 辅助办法在前面
    if (path === -1) return ctx.throw(400, 'failed to decode');

    // 如果申请以 / 结尾,必定是一个文件夹,将 path 改为文件夹上面的默认文件
    if (index && trailingSlash) path += index;

    // resolvePath 能够将一个根门路和申请的相对路径合并成一个绝对路径
    // 并且避免一些常见的攻打,比方 GET /../file.js
    // GitHub 地址:https://github.com/pillarjs/resolve-path
    path = resolvePath(root, path)

    // 用 fs.stat 获取文件的根本信息,顺便检测下文件存在不
    let stats;
    try {stats = await stat(path)

        // 如果是文件夹,并且 format 为 true,拼上 index 文件
        if (stats.isDirectory()) {if (format && index) {path += `/${index}`
                stats = await stat(path)
            } else {return}
        }
    } catch (err) {
        // 错误处理,如果是文件不存在,返回 404,否则返回 500
        const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
        if (notfound.includes(err.code)) {
              // createError 来自 http-errors 库,能够疾速创立 HTTP 谬误对象
            // github 地址:https://github.com/jshttp/http-errors
            throw createError(404, err)
        }
        err.status = 500
        throw err
    }

    // 设置 Content-Length 的 header
    ctx.set('Content-Length', stats.size)

    // 设置缓存管制 header
    if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString())
    if (!ctx.response.get('Cache-Control')) {const directives = [`max-age=${(maxage / 1000 | 0)}`]
        if (immutable) {directives.push('immutable')
        }
        ctx.set('Cache-Control', directives.join(','))
    }

    // 设置返回类型和返回内容
   if (!ctx.type) ctx.type = extname(path)
    ctx.body = fs.createReadStream(path)

    return path
}

function decode(path) {
    try {return decodeURIComponent(path)
    } catch (err) {return -1}
}

上述代码并没有太简单的逻辑,先拼一个残缺的地址,而后应用 fs.stat 获取文件的根本信息,如果文件不存在,这个 API 就报错了,间接返回 404。如果文件存在,就用fs.stat 拿到的信息设置 Content-Length 和一些缓存管制的 header。

koa-send的源码也只有一个文件,百来行代码:https://github.com/koajs/send/blob/master/index.js

ctx.type 和 ctx.body

上述代码咱们看到最初并没有间接返回文件,而只是设置了 ctx.typectx.body这两个值就完结了,为啥设置了这两个值,文件就主动返回了呢?要晓得这个原理,咱们要联合 Koa 源码来看。

之前讲 Koa 源码的时候我提到过,他扩大了 Node 原生的 res,并且在外面给type 属性增加了一个 set 办法:

set type(type) {type = getType(type);
  if (type) {this.set('Content-Type', type);
  } else {this.remove('Content-Type');
  }
}

这段代码的作用是当你给 ctx.type 设置值的时候,会主动给 Content-Type 设置值,getType其实是另一个第三方库 cache-content-type,他能够依据你传入的文件类型,返回匹配的MIME type。我刚看koa-static 源码时,找了半天也没找到在哪里设置的 Content-Type,前面发现是在Koa 源码外面。所以设置了 ctx.type 其实就是设置了Content-Type

koa扩大的 type 属性看这里:https://github.com/koajs/koa/blob/master/lib/response.js#L308

之前讲 Koa 源码的时候我还提到过,当所有中间件都运行完了,最初会运行一个办法 respond 来返回后果,在那篇文章外面,respond是简化版的,间接用 res.end 返回了后果:

function respond(ctx) {
  const res = ctx.res; // 取出 res 对象
  const body = ctx.body; // 取出 body

  return res.end(body); // 用 res 返回 body
}

间接用 res.end 返回后果只能对一些简略的小对象比拟适合,比方字符串什么的。对于简单对象,比方文件,这个就适合了,因为你如果要用 res.write 或者 res.end 返回文件,你须要先把文件整个读入内存,而后作为参数传递,如果文件很大,服务器内存可能就爆了 。那要怎么解决呢?回到koa-send 源码外面,咱们给 ctx.body 设置的值其实是一个可读流:

ctx.body = fs.createReadStream(path)

这种流怎么返回呢?其实 Node.js 对于返回流自身就有很好的反对。要返回一个值,须要用到 http 回调函数外面的 res,这个res 自身其实也是一个流。大家能够再翻翻 Node.js 官网文档,这里的 res 其实是 http.ServerResponse 类的一个实例,而 http.ServerResponse 自身又继承自 Stream 类:

所以 res 自身就是一个流 Stream,那Stream 的 API 就能够用了 ctx.body 是应用 fs.createReadStream 创立的,所以他是一个可读流,可读流有一个很不便的 API 能够间接让内容流动到可写流:readable.pipe,应用这个 API,Node.js会主动将可读流外面的内容推送到可写流,数据流会被主动治理,所以即便可读流更快,指标可写流也不会超负荷,而且即便你文件很大,因为不是一次读入内存,而是流式读入,所以也不会爆。所以咱们在 Koarespond外面反对下流式 body 就行了:

function respond(ctx) {
  const res = ctx.res; 
  const body = ctx.body; 
  
  // 如果 body 是个流,间接用 pipe 将它绑定到 res 上
  if (body instanceof Stream) return body.pipe(res);

  return res.end(body); 
}

Koa源码对于流的解决看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L267

总结

当初,咱们能够用本人写的 koa-static 来替换官网的了,运行成果是一样的。最初咱们再来回顾下本文的要点:

  1. 本文是 Koa 罕用动态服务中间件 koa-static 的源码解析。
  2. 因为是一个 Koa 的中间件,所以 koa-static 的返回值是一个办法,而且须要合乎中间件范式: (ctx, next) => {}
  3. 作为一个动态服务中间件,koa-static本应该实现以下几件事件:

    1. 通过申请门路取出正确的文件地址
    2. 通过地址获取对应的文件
    3. 应用 Node.js 的 API 返回对应的文件,并设置相应的header

    然而这几件事件他一件也没干,都扔给 koa-send 了,所以他官网文档也说了他只是wrapper for koa-send.

  4. 作为一个 wrapper 他还反对了一个比拟非凡的配置项 opt.defer,这个配置项能够管制他在所有Koa 中间件外面的执行机会,其实就是调用 next 的机会。如果你给这个参数传了 true,他就先调用next,让其余中间件先执行,本人最初执行,反之亦然。有了这个参数,你能够将/test.jpg 这种申请先作为一般路由解决,路由没匹配上再尝试动态文件,这在某些场景下很有用。
  5. koa-send才是真正解决动态文件,他把后面说的三件事全干了,在拼接文件门路时还应用了 resolvePath 来进攻常见攻打。
  6. koa-send取文件时应用了 fs 模块的 API 创立了一个可读流,并将它赋值给ctx.body,同时设置了ctx.type
  7. 通过 ctx.typectx.body返回给请求者并不是 koa-send 的性能,而是 Koa 自身的性能。因为 http 模块提供和的 res 自身就是一个可写流,所以咱们能够通过可读流的 pipe 函数间接将 ctx.body 绑定到 res 上,剩下的工作 Node.js 会主动帮咱们实现。
  8. 应用流 (Stream) 来读写文件有以下几个长处:

    1. 不必一次性将文件读入内存,暂用内存小。
    2. 如果文件很大,一次性读完整个文件,可能耗时较长。应用流,能够一点一点读文件,读到一点就能够返回给response,有更快的响应工夫。
    3. Node.js能够在可读流和可写流之间应用管道进行数据传输,应用也很不便。

参考资料:

koa-static文档:https://github.com/koajs/static

koa-static源码:https://github.com/koajs/static/blob/master/index.js

koa-send文档:https://github.com/koajs/send

koa-send源码:https://github.com/koajs/send/blob/master/index.js

文章的最初,感激你破费贵重的工夫浏览本文,如果本文给了你一点点帮忙或者启发,请不要悭吝你的赞和 GitHub 小星星,你的反对是作者继续创作的能源。

作者博文 GitHub 我的项目地址:https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢送关注~

退出移动版