这篇文章继续前面的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

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

手写koa-static源码,深入理解静态服务器原理的更多相关文章

  1. 手写@koa/router源码

    上一篇文章我们讲了Koa的基本架构,可以看到Koa的基本架构只有中间件内核,并没有其他功能,路由功能也没有.要实现路由功能我们必须引入第三方中间件,本文要讲的路由中间件是@koa/router,这个中 ...

  2. 手写Koa.js源码

    用Node.js写一个web服务器,我前面已经写过两篇文章了: 第一篇是不使用任何框架也能搭建一个web服务器,主要是熟悉Node.js原生API的使用:使用Node.js原生API写一个web服务器 ...

  3. 手写Express.js源码

    上一篇文章我们讲了怎么用Node.js原生API来写一个web服务器,虽然代码比较丑,但是基本功能还是有的.但是一般我们不会直接用原生API来写,而是借助框架来做,比如本文要讲的Express.通过上 ...

  4. 模拟源码深入理解Vue数据驱动原理(2)

    我们说到如果监听的属性是个对象呢?那么这个对象中的其他属性岂不就是监听不了了吗?如下: 倘若user中的name.age属性变化,如何知道它们变化了呢?今儿,就来解决这一问题. 通过走读Vue源码,发 ...

  5. 模拟源码深入理解Vue数据驱动原理(1)

    Vue有一核心就是数据驱动(Data Driven),允许我们采用简洁的模板语法来声明式的将数据渲染进DOM,且数据与DOM是绑定在一起的,这样当我们改变Vue实例的数据时,对应的DOM元素也就会改变 ...

  6. 从源码角度理解Java设计模式——装饰者模式

    一.饰器者模式介绍 装饰者模式定义:在不改变原有对象的基础上附加功能,相比生成子类更灵活. 适用场景:动态的给一个对象添加或者撤销功能. 优点:可以不改变原有对象的情况下动态扩展功能,可以使扩展的多个 ...

  7. storm源码之理解Storm中Worker、Executor、Task关系 + 并发度详解

    本文导读: 1 Worker.Executor.task详解 2 配置拓扑的并发度 3 拓扑示例 4 动态配置拓扑并发度 Worker.Executor.Task详解: Storm在集群上运行一个To ...

  8. C写的扫描器源码

    Title:C写的扫描器源码 --2010-10-27 20:02 无意间看见的一个源代码,弄回来读下. ----------------------------------------------- ...

  9. React-setState源码的理解

    首先举一个最简单的例子: this.state={ a:1 } this.setState({ a:2 }) console.log(this.state.a)//1 可以说setState()操作是 ...

随机推荐

  1. 第4章 Function语意学

    第4章 Function语意学 目录 第4章 Function语意学 4.1 Member的各种调用方式 Nonstatic Member Function(非静态成员函数) virtual Memb ...

  2. 获取List集合对象中某一列属性值

    例:获取disposeList集合中CorpusMarkPage对象中的responseId属性,生成新的List集合 List<String> responseIdList = disp ...

  3. 微信小程序获取二维码API

    <%@ WebHandler Language="C#" Class="ce" %> using System; using System.Web; ...

  4. 释放至强平台 AI 加速潜能 汇医慧影打造全周期 AI 医学影像解决方案

    基于英特尔架构实现软硬协同加速,显著提升新冠肺炎.乳腺癌等疾病的检测和筛查效率,并帮助医疗科研平台预防"维度灾难"问题 <PAGE 1 LEFT COLUMN: CUSTOM ...

  5. 【转载】VirtualBox 扩展增强包安装

    1 扩展包作用 鼠标可自动在虚拟机和物理机中切换状态,而不用按快捷键解除独占功能 安装了扩展包后,可以解决 virtualbox 中 更改 ubuntu 分辨率无效的问题 2 原文地址 星朝 - Vi ...

  6. Core WebApi项目快速入门(三):踩坑笔记

    目前做公司一个项目,遇到了一些坑.跟大家分享,避免再次采坑. 1. 服务端发布应用报错 在windows server上发布程序报错.系统缺少更新包. https://support.microsof ...

  7. vs code远程开发

    VS Code如何配置远程开发 你是如何远程开发的?还在使用FTP/SFTP同步文件?那你out了,有了宇宙第一IDE:VS就不需要这么麻烦了,一起学习一下吧. 第一步,安装Remote SSH插件 ...

  8. Linux mysql 修改密码 三种方式(转载)

    注明:本文为转载,原文地址:https://www.cnblogs.com/chuckjam/archive/2018/08/10/9456255.html 前言 有时我们会忘记Mysql的密码,或者 ...

  9. SSH2中的笔记

    1.web后端实质就是对表的添.删.查.改: 第一步:对系统进行分析,然后构思. 第二步:画出E-R图,设计出表或写出相应的实体类. 第三步:按照最简单的思想去设计的话,一个action-->一 ...

  10. ubuntu配置简单的DNS服务器

    之所以说是简单的服务器,实现的功能很简单,通过这个dns server 查询制定域名的时候,能够根据设置的值来返回IP,当前的需求是需要轮询的返回IP DNS 轮询机制会受到多方面的影响,如:A记录的 ...