koa-send源码解析

介绍

Static file serving middleware.

意为静态文件服务中间件。简单来说就是,对于静态文件的获取,进行一系列的判断和错误处理,将命中文件的流放到 ctx.body,函数返回命中的文件路径。

源码 koa-send 5.0.1

send 函数接受三个参数:

  1. ctx:就是koa的上下文对象context
  2. pathkoa-static传过来的是ctx.path,这个值其实就是req.path
  3. opts: 一些配置项,defer会影响执行顺序,index设置默认首页路径,hidden是否可返回隐藏文件等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
async function send(ctx, path, opts = {}) {
// root则为配置的静态文件目录,不存在则使用当前目录
const root = opts.root ? normalize(resolve(opts.root)) : ''

// 看看path结尾是不是/,也就是判断传入path是否为目录
const trailingSlash = path[path.length - 1] === '/'

// 将ctx.path截取root部分,也就是根目录
path = path.substr(parse(path).root.length)

// 首页文件名称及格式,如index.html,当请求目录时默认返回
const index = opts.index

// http缓存控制Cache-Control的那个maxage
const maxage = opts.maxage || opts.maxAge || 0
const immutable = opts.immutable || false

// 是否可返回隐藏文件的参数
const hidden = opts.hidden || false

// format默认是true,用来支持/directory这种结尾不带/的文件目录请求
const format = opts.format !== false
// 自行传入的扩展文件格式数组
const extensions = Array.isArray(opts.extensions) ? opts.extensions : false

// 是否可返回压缩文件格式
const brotli = opts.brotli !== false
const gzip = opts.gzip !== false

const setHeaders = opts.setHeaders

// 判断setHeaders存在且为函数,否则抛错
if (setHeaders && typeof setHeaders !== 'function') {
throw new TypeError('option setHeaders must be function')
}

// 将path标准化转义,防止不同国家字母等,使用decodeURIComponent
path = decode(path)
if (path - === 1) return ctx.throw(400, 'failed to decode')

// 当请求目录时(类似/test/),首页index文件路径拼入path(/test/index.html)
if (index && trailingSlash) path += index

// resolvePath可以将一个根路径和请求的相对路径合并成一个绝对路径
path = resolvePath(root, path)

// 若请求的是隐藏文件,则无视掉直接return
if (!hidden && isHidden(root, path)) return

// 为后续type区分作为参数
let encodingExt = ''

// 优先考虑br和gzip格式的压缩文件,例如yasuo.gz文件在访问/public/yasuo会默认命中
// 判断req的accepts是否包含br格式,并且参数未设置关闭,并且请求的是该格式的文件也存在
if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) {
path = path + '.br'

// 设置Content-Encoding为br格式
ctx.set('Content-Encoding', 'br')

// 去除Content-Length的响应头
ctx.res.removeHeader('Content-Length')
encodingExt = '.br'

// 判断与上同理
} else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) {
path = path + '.gz'
ctx.set('Content-Encoding', 'gzip')
ctx.res.removeHeader('Content-Length')
encodingExt = '.gz'
}

// extensions扩展数组存在,且当前路径不包含文件后缀
// 使用extensions中的后缀去匹配文件,存在则返回
if (extensions && !/\./.exec(basename(path))) {
const list = [].concat(extensions)
for (let i = 0; i < list.length; i++) {
let ext = list[i]
if (typeof ext !== 'string') {
throw new TypeError('option extensions must be array of strings or false')
}
// 传入文件后缀的字符串若没有.就补上
if (!/^\./.exec(ext)) ext = `.${ext}`
if (await exists(`${path}${ext}`)) {
path = `${path}${ext}`
break
}
}
}

// 如果以上均未命中,则可能请求的是文件目录
let stats
try {
// 使用fs.stat获取文件的基本信息,并检测文件是否存在
stats = await stat(path)

// 如果判断为文件目录,显示该目录下的默认首页,不存在则直接返回空
if (stats.isDirectory()) {
if (format && index) {
path += `/${index}`
stats = await 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
}

if (setHeaders) setHeaders(ctx.res, path, stats)

// 设置header的Content-Length
ctx.set('Content-Length', stats.size)
// 设置header的Cache-Control和Last-Modified
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 = type(path, encodingExt)

// 将文件设置为可读流到body中
ctx.body = fs.createReadStream(path)

return path
}

// 判断是否是隐藏文件,通过文件第一个字符是.
function isHidden(root, path) {
path = path.substr(root.length).split(sep)
for (let i = 0; i < path.length; i++) {
if (path[i][0] === '.') return true
}
return false
}

function type(file, ext) {
return ext !== '' ? extname(basename(file, ext)) : extname(file)
}

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

acceptsEncodings 函数

该函数源自koa中的request.js文件中,借用accepts库返回reqaccepts字段,该字段记录着大多数客户端推荐的文件类型。

1
2
3
acceptsEncodings(...args) {
return this.accept.encodings(...args);
},

type 函数

该函数源自koa中的response.js文件中,getType是使用cache-content-type库通过传入的type类型判断文件的MIME type设置到Content-Type,传入''则删除该字段。

1
2
3
4
5
6
7
8
set type(type) {
type = getType(type);
if (type) {
this.set('Content-Type', type);
} else {
this.remove('Content-Type');
}
},

ctx.body

ctx.body = fs.createReadStream(path)在最后一步,用该路径的文件创建一个可读流返回到 body 中。主要是因为流作为传输文件有着不错优势,极大节省内存空间和减小溢出风险。而使用res.writeres.end仅适用于字符串或小文件,否则文件过大,作为参数传递,内存爆炸风险很大。

参考链接

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

给阿姨来一杯卡普基诺~

支付宝
微信