koa
原始碼閱讀的第四篇,涉及到向介面請求方提供檔案資料。
第一篇:koa原始碼閱讀-0
第二篇:koa原始碼閱讀-1-koa與koa-compose
第三篇:koa原始碼閱讀-2-koa-router
處理靜態檔案是一個繁瑣的事情,因為靜態檔案都是來自於伺服器上,肯定不能放開所有許可權讓介面來讀取。
各種路徑的校驗,許可權的匹配,都是需要考慮到的地方。
而koa-send
和koa-static
就是幫助我們處理這些繁瑣事情的中介軟體。
koa-send
是koa-static
的基礎,可以在NPM
的介面上看到,static
的dependencies
中包含了koa-send
。
koa-send
主要是用於更方便的處理靜態檔案,與koa-router
之類的中介軟體不同的是,它並不是直接作為一個函式注入到app.use
中的。
而是在某些中介軟體中進行呼叫,傳入當前請求的Context
及檔案對應的位置,然後實現功能。
原生的檔案讀取、傳輸方式
在Node
中,如果使用原生的fs
模組進行檔案資料傳輸,大致是這樣的操作:
const fs = require('fs')
const Koa = require('koa')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()
const file = './test.log'
const port = 12306
router.get('/log', ctx => {
const data = fs.readFileSync(file).toString()
ctx.body = data
})
app.use(router.routes())
app.listen(port, () => console.log(`Server run as http://127.0.0.1:${port}`))
複製程式碼
或者用createReadStream
代替readFileSync
也是可行的,區別會在下邊提到
這個簡單的示例僅針對一個檔案進行操作,而如果我們要讀取的檔案是有很多個,甚至於可能是通過介面引數傳遞過來的。
所以很難保證這個檔案一定是真實存在的,而且我們可能還需要新增一些許可權設定,防止一些敏感檔案被介面返回。
router.get('/file', ctx => {
const { fileName } = ctx.query
const path = path.resolve('./XXX', fileName)
// 過濾隱藏檔案
if (path.startsWith('.')) {
ctx.status = 404
return
}
// 判斷檔案是否存在
if (!fs.existsSync(path)) {
ctx.status = 404
return
}
// balabala
const rs = fs.createReadStream(path)
ctx.body = rs // koa做了針對stream型別的處理,詳情可以看之前的koa篇
})
複製程式碼
新增了各種邏輯判斷以後,讀取靜態檔案就變得安全不少,可是這也只是在一個router
中做的處理。
如果有多個介面都會進行靜態檔案的讀取,勢必會存在大量的重複邏輯,所以將其提煉為一個公共函式將是一個很好的選擇。
koa-send的方式
這就是koa-send
做的事情了,提供了一個封裝非常完善的處理靜態檔案的中介軟體。
這裡是兩個最基礎的使用例子:
const path = require('path')
const send = require('koa-send')
// 針對某個路徑下的檔案獲取
router.get('/file', async ctx => {
await send(ctx, ctx.query.path, {
root: path.resolve(__dirname, './public')
})
})
// 針對某個檔案的獲取
router.get('/index', async ctx => {
await send(ctx, './public/index.log')
})
複製程式碼
假設我們的目錄結構是這樣的,simple-send.js
為執行檔案:
.
├── public
│ ├── a.log
│ ├── b.log
│ └── index.log
└── simple-send.js
複製程式碼
使用/file?path=XXX
就可以很輕易的訪問到public
下的檔案。
以及訪問/index
就可以拿到/public/index.log
檔案的內容。
koa-send提供的功能
koa-send
提供了很多便民的選項,除去常用的root
以外,還有大概小十個的選項可供使用:
options | type | default | desc |
---|---|---|---|
maxage |
Number |
0 |
設定瀏覽器可以快取的毫秒數 對應的 Header : Cache-Control: max-age=XXX |
immutable |
Boolean |
false |
通知瀏覽器該URL對應的資源不可變,可以無限期的快取 對應的 Header : Cache-Control: max-age=XXX, immutable |
hidden |
Boolean |
false |
是否支援隱藏檔案的讀取. 開頭的檔案被稱為隱藏檔案 |
root |
String |
- | 設定靜態檔案路徑的根目錄,任何該目錄之外的檔案都是禁止訪問的。 |
index |
String |
- | 設定一個預設的檔名,在訪問目錄的時候生效,會自動拼接到路徑後邊 (此處有一個小彩蛋) |
gzip |
Boolean |
true |
如果訪問介面的客戶端支援gzip ,並且存在.gz 字尾的同名檔案的情況下會傳遞.gz 檔案 |
brotli |
Boolean |
true |
邏輯同上,如果支援brotli 且存在.br 字尾的同名檔案 |
format |
Boolean |
true |
開啟以後不會強要求路徑結尾的/ ,/path 和/path/ 表示的是一個路徑 (僅在path 是一個目錄的情況下生效) |
extensions |
Array |
false |
如果傳遞了一個陣列,會嘗試將陣列中的所有item 作為檔案的字尾進行匹配,匹配到哪個就讀取哪個檔案 |
setHeaders |
Function |
- | 用來手動指定一些Headers ,意義不大 |
引數們的具體表現
有些引數的搭配可以實現一些神奇的效果,有一些引數會影響到Header
,也有一些引數是用來優化效能的,類似gzip
和brotli
的選項。
koa-send
的主要邏輯可以分為這幾塊:
path
路徑有效性的檢查gzip
等壓縮邏輯的應用- 檔案字尾、預設入口檔案的匹配
- 讀取檔案資料
在函式的開頭部分有這樣的邏輯:
const resolvePath = require('resolve-path')
const {
parse
} = require('path')
async function send (ctx, path. opts = {}) {
const trailingSlash = path[path.length - 1] === '/'
const index = opts.index
// 此處省略各種引數的初始值設定
path = path.substr(parse(path).root.length)
// ...
// normalize path
path = decode(path) // 內部呼叫的是`decodeURIComponent`
// 也就是說傳入一個轉義的路徑也是可以正常使用的
if (index && trailingSlash) path += index
path = resolvePath(root, path)
// hidden file support, ignore
if (!hidden && isHidden(root, path)) return
}
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
}
複製程式碼
路徑檢查
首先是判斷傳入的path
是否為一個目錄,(結尾為/
會被認為是一個目錄)。
如果是目錄,並且存在一個有效的index
引數,則會將index
拼接到path
後邊。
也就是大概這樣的操作:
send(ctx, './public/', {
index: 'index.js'
})
// ./public/index.js
複製程式碼
resolve-path 是一個用來處理路徑的包,用來幫助過濾一些異常的路徑,類似path//file
、/etc/XXX
這樣的惡意路徑,並且會返回處理後絕對路徑。
isHidden
用來判斷是否需要過濾隱藏檔案。
因為但凡是.
開頭的檔案都會被認為隱藏檔案,同理目錄使用.
開頭也會被認為是隱藏的,所以就有了isHidden
函式的實現。
其實我個人覺得這個使用一個正則就可以解決的問題。。為什麼還要分割為陣列呢?
function isHidden (root, path) {
path = path.substr(root.length)
return new RegExp(`${sep}\\.`).test(path)
}
複製程式碼
已經給社群提交了PR
。
壓縮的開啟與資料夾的處理
在上邊的這一坨程式碼執行完以後,我們就得到了一個有效的路徑,(如果是無效路徑,resolvePath
會直接丟擲異常)
接下來做的事情就是檢查是否有可用的壓縮檔案使用,此處沒有什麼邏輯,就是簡單的exists
操作,以及Content-Encoding
的修改 (用於開啟壓縮)。
字尾的匹配:
if (extensions && !/\.[^/]*$/.exec(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 fs.exists(path + ext)) {
path = path + ext
break
}
}
}
複製程式碼
可以看到這裡的遍歷是完全按照我們呼叫send
是傳入的順序來走的,並且還做了.
符號的相容。
也就是說這樣的呼叫都是有效的:
await send(ctx, 'path', {
extensions: ['.js', 'ts', '.tsx']
})
複製程式碼
如果在新增了字尾以後能夠匹配到真實的檔案,那麼就認為這是一個有效的路徑,然後進行了break
的操作,也就是文件中所說的:First found is served.
。
在結束這部分操作以後會進行目錄的檢測,判斷當前路徑是否為一個目錄:
let stats
try {
stats = await fs.stat(path)
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
}
複製程式碼
一個小彩蛋
可以發現一個很有意思的事情,如果發現當前路徑是一個目錄以後,並且明確指定了format
,那麼還會再嘗試拼接一次index
。
這就是上邊所說的那個彩蛋了,當我們的public
路徑結構長得像這樣的時候:
└── public
└── index
└── index # 實際的檔案 hello
複製程式碼
我們可以通過一個簡單的方式獲取到最底層的檔案資料:
router.get('/surprises', async ctx => {
await send(ctx, '/', {
root: './public',
index: 'index'
})
})
// > curl http://127.0.0.1:12306/surprises
// hello
複製程式碼
這裡就用到了上邊的幾個邏輯處理,首先是trailingSlash
的判斷,如果以/
結尾會拼接index
,以及如果當前path
匹配為是一個目錄以後,又會拼接一次index
。
所以一個簡單的/
加上index
的引數就可以直接獲取到/index/index
。
一個小小的彩蛋,實際開發中應該很少會這麼玩
最終的讀取檔案操作
最後終於來到了檔案讀取的邏輯處理,首先就是呼叫setHeaders
的操作。
因為經過上邊的層層篩選,這裡拿到的path
和你呼叫send
時傳入的path
不是同一個路徑。
不過倒也沒有必要必須在setHeaders
函式中進行處理,因為可以看到在函式結束時,將實際的path
返回了出來。
我們完全可以在send
執行完畢後再進行設定,至於官方readme
中所寫的and doing it after is too late because the headers are already sent.
。
這個不需要擔心,因為koa
的返回資料都是放到ctx.body
中的,而body
的解析是在所有的中介軟體全部執行完以後才會進行處理。
也就是說所有的中介軟體都執行完以後才會開始傳送http
請求體,在此之前設定Header
都是有效的。
if (setHeaders) setHeaders(ctx.res, path, stats)
// stream
ctx.set('Content-Length', stats.size)
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) // 介面返回的資料型別,預設會取出檔案字尾
ctx.body = fs.createReadStream(path)
return path
複製程式碼
以及包括上邊的maxage
和immutable
都是在這裡生效的,但是要注意的是,如果Cache-Control
已經存在值了,koa-send
是不會去覆蓋的。
使用Stream與使用readFile的區別
在最後給body
賦值的位置可以看到,是使用的Stream
而並非是readFile
,使用Stream
進行傳輸能帶來至少兩個好處:
- 第一種方式,如果是大檔案,在讀取完成後會臨時存放到記憶體中,並且
toString
是有長度限制的,如果是一個巨大的檔案,toString
呼叫會丟擲異常的。 - 採用第一種方式進行讀取檔案,是要在全部的資料都讀取完成後再返回給介面呼叫方,在讀取資料的期間,介面都是處於
Wait
的狀態,沒有任何資料返回。
可以做一個類似這樣的Demo:
const http = require('http')
const fs = require('fs')
const filePath = './test.log'
http.createServer((req, res) => {
if (req.url === '/') {
res.end('<html></html>')
} else if (req.url === '/sync') {
const data = fs.readFileSync(filePath).toString()
res.end(data)
} else if (req.url === '/pipe') {
const rs = fs.createReadStream(filePath)
rs.pipe(res)
} else {
res.end('404')
}
}).listen(12306, () => console.log('server run as http://127.0.0.1:12306'))
複製程式碼
首先訪問首頁http://127.0.0.1:12306/
進入一個空的頁面 (主要是懶得搞CORS
了),然後在控制檯呼叫兩個fetch
就可以得到這樣的對比結果了:
可以看出在下行傳輸的時間相差無幾的同時,使用readFileSync
的方式會增加一定時間的Waiting
,而這個時間就是伺服器在進行檔案的讀取,時間長短取決於讀取的檔案大小,以及機器的效能。
koa-static
koa-static
是一個基於koa-send
的淺封裝。
因為通過上邊的例項也可以看到,send
方法需要自己在中介軟體中呼叫才行。
手動指定send
對應的path
之類的引數,這些也是屬於重複性的操作,所以koa-static
將這些邏輯進行了一次封裝。
讓我們可以通過直接註冊一箇中介軟體來完成靜態檔案的處理,而不再需要關心引數的讀取之類的問題:
const Koa = require('koa')
const app = new Koa()
app.use(require('koa-static')(root, opts))
複製程式碼
opts
是透傳到koa-send
中的,只不過會使用第一個引數root
來覆蓋opts
中的root
。
並且新增了一些細節化的操作:
- 預設新增一個
index.html
if (opts.index !== false) opts.index = opts.index || 'index.html'
複製程式碼
- 預設只針對
HEAD
和GET
兩種METHOD
if (ctx.method === 'HEAD' || ctx.method === 'GET') {
// ...
}
複製程式碼
- 新增一個
defer
選項來決定是否先執行其他中介軟體。
如果defer
為false
,則會先執行send
,優先匹配靜態檔案。
否則則會等到其餘中介軟體先執行,確定其他中介軟體沒有處理該請求才會去尋找對應的靜態資源。
只需指定root
,剩下的工作交給koa-static
,我們就無需關心靜態資源應該如何處理了。
小結
koa-send
與koa-static
算是兩個非常輕量級的中介軟體了。
本身沒有太複雜的邏輯,就是一些重複的邏輯被提煉成的中介軟體。
不過確實能夠減少很多日常開發中的任務量,可以讓人更專注的關注業務,而非這些邊邊角角的功能。