通常我们在做静态文件服务的时候,首选 CDN。当文件内容需要经常变动时,则可以采用 nginx 代理的方式。node 本身也可以搭建静态服务,用 koa static 可以很容易实现这个功能。
koa static 是一个 koa 中间件,内部是对 koa send 的封装。koa static 本身只做了一层简单的逻辑,所以这篇文章主要分析一下 koa send 的实现方式。
如果让我们自己实现这个功能,也很简单,逻辑就是根据用户请求路径,找到文件,然后做一个文件流的响应。
koa send 的实现也大概是这个思路,另外多了一些基于 http 协议的处理,当然,阅读 koa send 的源码,还是有一些意外的收获。
koa send 源码很简洁,唯一暴露了一个工具函数 send,send 函数大致结构如下:
async function send(ctx, path, opts = {}) {
// 1、参数 path 校验
// 2、配置 opts 初始化
// 3、accept encoding 处理
// 4、404、500 处理
// 5、缓存头处理
// 6、流响应
}
第 1 步和第 2 步是 koa send 本身的一些配置的处理,代码比较啰嗦,我们可以忽略。
第 3 步,主要是根据请求头 Accept-Encoding 进行处理,如果用户浏览器支持 br 或者 gzip 的压缩方式,koa send 会判断是否存在 br 或者 gz 格式文件,如果存在会优先响应 br 或者 gz 文件。
第 4 步,会做文件查找,如果不存在文件,或者文件查找异常,则进行 404 或者 500 的响应。具体代码如下:
try {
stats = await fs.stat(path)
// 默认文件 index
if (stats.isDirectory()) {
if (format && index) {
path += ‘/’ + index
stats = await fs.stat(path)
} else {
return
}
}
} catch (err) {
const notfound = [‘ENOENT’, ‘ENAMETOOLONG’, ‘ENOTDIR’]
if (notfound.includes(err.code)) {
throw createError(404, err)
}
err.status = 500
throw err
}
第 5 步,会设置协商缓存 Last-Modified 和强制缓存 Cache-Control,代码很简单不解释,不过这里面有一个之前没遇到的知识点,koa send 设置的 Cache-Control 会有类似 max-age=10000,immutable 的值,immutable 表示永不改变,浏览器永不需要请求资源,这个感觉可以配合带 hash 或者版本号的资源使用。
第 6 步最有意思,代码很简单,只有一行:
ctx.body = fs.createReadStream(path)
熟悉 node 文件流的同学都会知道,fs.createReadStream 创建了一个自 path 的文件流,调用.pipe 即可通过管道做文件流传输。比如使用 node 的 http 模块实现的 http 服务,如果要对 res 做文件流响应,那么只要调用 strame.pipe(res) 即可。
但是这里有意思的是,koa send 把 stream 赋值给 ctx.body 就完了,没有看到任何流处理。另外平时我们做 json 格式的响应,也是类似的调用 ctx.body={a:1}。也就是说,ctx.body 可以做不同类型的赋值操作。要解释这个操作方式,就要回到 koa 本身对 koa.body 的实现。
先贴上 koa body 的实现代码:
set body(val) {
// no content
if (null == val) {
if (!statuses.empty[this.status]) this.status = 204;
this.remove(‘Content-Type’);
this.remove(‘Content-Length’);
this.remove(‘Transfer-Encoding’);
return;
}
// string
if (‘string’ == typeof val) {
if (setType) this.type = /^s*</.test(val) ? ‘html’ : ‘text’;
this.length = Buffer.byteLength(val);
return;
}
// buffer
if (Buffer.isBuffer(val)) {
if (setType) this.type = ‘bin’;
this.length = val.length;
return;
}
// stream
if (‘function’ == typeof val.pipe) {
// 结束关闭流 stream.destroy stream.close
// https://www.npmjs.com/package/on-finished
// https://www.npmjs.com/package/destroy
onFinish(this.res, destroy.bind(null, val));
ensureErrorHandler(val, err => this.ctx.onerror(err));
// overwriting
if (null != original && original != val) this.remove(‘Content-Length’);
if (setType) this.type = ‘bin’;
return;
}
// json
this.remove(‘Content-Length’);
this.type = ‘json’;
}
从截取的源码可以看到,原来是 ctx.body 做了一层 setter 拦截,当我们赋值的时候,koa 对于不同格式的 body,统一在 setter 中做了分类处理。从源码上看,body 依次支持了空值、字符串、字节、文件流和 json 几种响应类型。对于是否流的判断,就是通过是否对象存在 pipe 函数确定的。
这个实现方式挺有意思,以后对于一些不同类型的复制操作,可以把类型判断和一些逻辑放到 setter 中来做,代码会清晰很多。