手写koa-static源码,深刻理解静态服务器原理

这篇文章继续前面的Koa源码系列,这个系列已经有两篇文章了:javascript

1.第一篇讲解了Koa的核心架构和源码:手写Koa.js源码[1]2.第二篇讲解了@koa/router的架构和源码:手写@koa/router源码[2]html

本文会接着讲一个经常使用的中间件----koa-static,这个中间件是用来搭建静态服务器的。前端

其实在我以前使用Node.js原生API写一个web服务器[3]已经讲过怎么返回一个静态文件了,代码虽然比较丑,基本流程仍是差很少的:java

1.经过请求路径取出正确的文件地址2.经过地址获取对应的文件3.使用Node.js的API返回对应的文件,并设置相应的headernode

koa-static的代码更通用,更优雅,并且对大文件有更好的支持,下面咱们来看看他是怎么作的吧。本文仍是采用一向套路,先看一下他的基本用法,而后从基本用法入手去读源码,并手写一个简化版的源码来替换他。git

基本用法

koa-static使用很简单,主要代码就一行:github

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的实例直接引用这个中间件就好了。web

serve方法支持两个参数,第一个是静态文件的目录,第二个参数是一些配置项,能够不传。像上面的代码serve('public')就表示public文件夹下面的文件均可以被外部访问。好比我在里面放了一张图片:api

跑起来就是这样子:promise

注意上面这个路径请求的是/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源码的文章里面已经讲过了[4],调用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的那个上下文ctx2.pathkoa-static传过来的是ctx.path,看过koa源码解析的应该知道,这个值其实就是req.path3.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[5],他能够根据你传入的文件类型,返回匹配的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官方文档[6],这里的res实际上是http.ServerResponse类的一个实例,而http.ServerResponse自己又继承自Stream类:

因此res自己就是一个流Stream,那Stream的API就能够用了ctx.body是使用fs.createReadStream建立的,因此他是一个可读流,可读流有一个很方便的API能够直接让内容流动到可写流:readable.pipe[7],使用这个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

做者掘金文章汇总:https://juejin.im/post/5e3ffc85518825494e2772fd

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

References

[1] 手写Koa.js源码: https://juejin.cn/post/6892952604163342344
[2] 手写@koa/router源码: https://juejin.cn/post/6895594434843869197
[3] 使用Node.js原生API写一个web服务器: https://juejin.cn/post/6887797543212843016
[4] 讲Koa源码的文章里面已经讲过了: https://juejin.cn/post/6892952604163342344
[5] cache-content-typehttps://github.com/node-modules/cache-content-type
[6] 你们能够再翻翻Node.js官方文档: http://nodejs.cn/api/http.html#http_class_http_serverresponse
[7] 可读流有一个很方便的API能够直接让内容流动到可写流:readable.pipehttp://nodejs.cn/api/stream.html#stream_readable_pipe_destination_options


本文分享自微信公众号 - 进击的大前端(AdvanceOnFE)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索