摘要:源碼閱讀的第四篇,涉及到向接口請求方提供文件數據。是一個基于的淺封裝。小結與算是兩個非常輕量級的中間件了。
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及文件對應的位置,然后實現功能。
koa-send的GitHub地址
原生的文件讀取、傳輸方式在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做的事情了,提供了一個封裝非常完善的處理靜態文件的中間件。
這里是兩個最基礎的使用例子:
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是不會去覆蓋的。
使用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("") } 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-statickoa-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算是兩個非常輕量級的中間件了。
本身沒有太復雜的邏輯,就是一些重復的邏輯被提煉成的中間件。
不過確實能夠減少很多日常開發中的任務量,可以讓人更專注的關注業務,而非這些邊邊角角的功能。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/97001.html
摘要:從中間件學習搭建靜態文件服務器原文地址中有說明它只是的一個包裝查看的源碼可以發現,它做的工作是根據傳入的查找文件是否存在,如果存在就創建一個流,不存在就拋出錯誤。 從koa-static中間件學習搭建靜態文件服務器 原文地址 koa-send Static file serving middleware koa-static中有說明它只是koa-send的一個包裝 const send...
摘要:吼,所以我做了,和一樣,都是對的一層封裝,只不過用編寫,對支持良好。 其實還是得按自個兒的需求來。 koa-static 有啥問題么 koa-static是一個非常輕量的koa中間件,能夠迅速的搭建起一個靜態文件服務器,通常我們把靜態文件都放進public,并且通過類似koa-static這樣的東西來將我們的public作為靜態目錄,這樣的話,我們就能直接通過根路由進行訪問了。 emm...
摘要:吼,所以我做了,和一樣,都是對的一層封裝,只不過用編寫,對支持良好。 其實還是得按自個兒的需求來。 koa-static 有啥問題么 koa-static是一個非常輕量的koa中間件,能夠迅速的搭建起一個靜態文件服務器,通常我們把靜態文件都放進public,并且通過類似koa-static這樣的東西來將我們的public作為靜態目錄,這樣的話,我們就能直接通過根路由進行訪問了。 emm...
摘要:本筆記共四篇源碼閱讀筆記源碼閱讀筆記源碼閱讀筆記服務器啟動與請求處理源碼閱讀筆記對象起因前兩天終于把自己一直想讀的源代碼讀了一遍。首先放上關鍵的源代碼在上一篇源碼閱讀筆記服務器啟動與請求處理中,我們已經分析了的作用。 本筆記共四篇Koa源碼閱讀筆記(1) -- coKoa源碼閱讀筆記(2) -- composeKoa源碼閱讀筆記(3) -- 服務器の啟動與請求處理Koa源碼閱讀筆記(4...
閱讀 689·2023-04-25 19:53
閱讀 4262·2021-09-22 15:13
閱讀 2565·2019-08-30 10:56
閱讀 1320·2019-08-29 16:27
閱讀 2932·2019-08-29 14:00
閱讀 2407·2019-08-26 13:56
閱讀 426·2019-08-26 13:29
閱讀 1611·2019-08-26 11:31