koa
源碼閱讀的第四篇,涉及到向接口請求方提供文件數據。html
第一篇:koa源碼閱讀-0
第二篇:koa源碼閱讀-1-koa與koa-compose
第三篇:koa源碼閱讀-2-koa-routergit
處理靜態文件是一個繁瑣的事情,由於靜態文件都是來自於服務器上,確定不能放開全部權限讓接口來讀取。
各類路徑的校驗,權限的匹配,都是須要考慮到的地方。
而koa-send
和koa-static
就是幫助咱們處理這些繁雜事情的中間件。koa-send
是koa-static
的基礎,能夠在NPM
的界面上看到,static
的dependencies
中包含了koa-send
。github
koa-send
主要是用於更方便的處理靜態文件,與koa-router
之類的中間件不一樣的是,它並非直接做爲一個函數注入到app.use
中的。
而是在某些中間件中進行調用,傳入當前請求的Context
及文件對應的位置,而後實現功能。npm
在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
作的事情了,提供了一個封裝很是完善的處理靜態文件的中間件。
這裏是兩個最基礎的使用例子:app
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
提供了不少便民的選項,除去經常使用的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
是不會去覆蓋的。
在最後給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-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
算是兩個很是輕量級的中間件了。自己沒有太複雜的邏輯,就是一些重複的邏輯被提煉成的中間件。不過確實可以減小不少平常開發中的任務量,可讓人更專一的關注業務,而非這些邊邊角角的功能。