这篇文章持续后面的Koa
源码系列,这个系列曾经有两篇文章了:
- 第一篇解说了
Koa
的外围架构和源码:手写Koa.js源码 - 第二篇解说了
@koa/router
的架构和源码:手写@koa/router源码
本文会接着讲一个罕用的中间件—-koa-static
,这个中间件是用来搭建动态服务器的。
其实在我之前应用Node.js原生API写一个web服务器曾经讲过怎么返回一个动态文件了,代码尽管比拟丑,根本流程还是差不多的:
- 通过申请门路取出正确的文件地址
- 通过地址获取对应的文件
- 应用
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
,如果你传了true
,koa-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
了,也就是说他应该把这几件事都干完:
- 通过申请门路取出正确的文件地址
- 通过地址获取对应的文件
- 应用
Node.js
的API返回对应的文件,并设置相应的header
因为koa-send
代码也不多,我就间接在代码中写正文了,通过后面的应用,咱们曾经晓得他的应用模式是:
send (ctx, path, opts)
他接管三个参数:
ctx
:就是koa
的那个上下文ctx
。path
:koa-static
传过来的是ctx.path
,看过koa
源码解析的应该晓得,这个值其实就是req.path
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.type
和ctx.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
会主动将可读流外面的内容推送到可写流,数据流会被主动治理,所以即便可读流更快,指标可写流也不会超负荷,而且即便你文件很大,因为不是一次读入内存,而是流式读入,所以也不会爆。所以咱们在Koa
的respond
外面反对下流式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
来替换官网的了,运行成果是一样的。最初咱们再来回顾下本文的要点:
- 本文是
Koa
罕用动态服务中间件koa-static
的源码解析。 - 因为是一个
Koa
的中间件,所以koa-static
的返回值是一个办法,而且须要合乎中间件范式:(ctx, next) => {}
-
作为一个动态服务中间件,
koa-static
本应该实现以下几件事件:- 通过申请门路取出正确的文件地址
- 通过地址获取对应的文件
- 应用
Node.js
的API返回对应的文件,并设置相应的header
然而这几件事件他一件也没干,都扔给
koa-send
了,所以他官网文档也说了他只是wrapper for koa-send.
- 作为一个
wrapper
他还反对了一个比拟非凡的配置项opt.defer
,这个配置项能够管制他在所有Koa
中间件外面的执行机会,其实就是调用next
的机会。如果你给这个参数传了true
,他就先调用next
,让其余中间件先执行,本人最初执行,反之亦然。有了这个参数,你能够将/test.jpg
这种申请先作为一般路由解决,路由没匹配上再尝试动态文件,这在某些场景下很有用。 koa-send
才是真正解决动态文件,他把后面说的三件事全干了,在拼接文件门路时还应用了resolvePath
来进攻常见攻打。koa-send
取文件时应用了fs
模块的API创立了一个可读流,并将它赋值给ctx.body
,同时设置了ctx.type
。- 通过
ctx.type
和ctx.body
返回给请求者并不是koa-send
的性能,而是Koa
自身的性能。因为http
模块提供和的res
自身就是一个可写流,所以咱们能够通过可读流的pipe
函数间接将ctx.body
绑定到res
上,剩下的工作Node.js
会主动帮咱们实现。 -
应用流(
Stream
)来读写文件有以下几个长处:- 不必一次性将文件读入内存,暂用内存小。
- 如果文件很大,一次性读完整个文件,可能耗时较长。应用流,能够一点一点读文件,读到一点就能够返回给
response
,有更快的响应工夫。 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
我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢送关注~
发表回复