手寫koa-static源碼,深入理解靜態服務器原理
- 2020 年 12 月 7 日
- 筆記
- koa-static, Koa.js, Node.js, 前端
這篇文章繼續前面的Koa
源碼系列,這個系列已經有兩篇文章了:
- 第一篇講解了
Koa
的核心架構和源碼:手寫Koa.js源碼 - 第二篇講解了
@koa/router
的架構和源碼:手寫@koa/router源碼
本文會接着講一個常用的中間件—-koa-static
,這個中間件是用來搭建靜態服務器的。
其實在我之前使用Node.js原生API寫一個web服務器已經講過怎麼返回一個靜態文件了,代碼雖然比較丑,基本流程還是差不多的:
- 通過請求路徑取出正確的文件地址
- 通過地址獲取對應的文件
- 使用
Node.js
的API返回對應的文件,並設置相應的header
koa-static
的代碼更通用,更優雅,而且對大文件有更好的支持,下面我們來看看他是怎麼做的吧。本文還是採用一貫套路,先看一下他的基本用法,然後從基本用法入手去讀源碼,並手寫一個簡化版的源碼來替換他。
基本用法
koa-static
使用很簡單,主要代碼就一行:
const Koa = require('koa');
const serve = require('koa-static');
const app = new Koa();
// 主要就是這行代碼
app.use(serve('public'));
app.listen(3001, () => {
console.log('listening on port 3001');
});
上述代碼中的serve
就是koa-static
,他運行後會返回一個Koa
中間件,然後Koa
的實例直接引用這個中間件就行了。
serve
方法支持兩個參數,第一個是靜態文件的目錄,第二個參數是一些配置項,可以不傳。像上面的代碼serve('public')
就表示public
文件夾下面的文件都可以被外部訪問。比如我在裏面放了一張圖片:
跑起來就是這樣子:
注意上面這個路徑請求的是/test.jpg
,前面並沒有public
,說明koa-static
對請求路徑進行了判斷,發現是文件就映射到服務器的public
目錄下面,這樣可以防止外部使用者探知服務器目錄結構。
手寫源碼
返回的是一個Koa
中間件
我們看到koa-static
導出的是一個方法serve
,這個方法運行後返回的應該是一個Koa
中間件,這樣Koa
才能引用他,所以我們先來寫一下這個結構吧:
module.exports = serve; // 導出的是serve方法
// serve接受兩個參數
// 第一個參數是路徑地址
// 第二個是配置選項
function serve(root, opts) {
// 返回一個方法,這個方法符合koa中間件的定義
return async function serve(ctx, next) {
await next();
}
}
調用koa-send
返迴文件
現在這個中間件是空的,其實他應該做的是將文件返回,返迴文件的功能也被單獨抽取出來成了一個庫—-koa-send
,我們後面會看他源碼,這裡先直接用吧。
function serve(root, opts) {
// 這行代碼如果效果就是
// 如果沒傳opts,opts就是空對象{}
// 同時將它的原型置為null
opts = Object.assign(Object.create(null), opts);
// 將root解析為一個合法路徑,並放到opts上去
// 因為koa-send接收的路徑是在opts上
opts.root = resolve(root);
// 這個是用來兼容文件夾的,如果請求路徑是一個文件夾,默認去取index
// 如果用戶沒有配置index,默認index就是index.html
if (opts.index !== false) opts.index = opts.index || 'index.html';
// 整個serve方法的返回值是一個koa中間件
// 符合koa中間件的範式: (ctx, next) => {}
return async function serve(ctx, next) {
let done = false; // 這個變量標記文件是否成功返回
// 只有HEAD和GET請求才響應
if (ctx.method === 'HEAD' || ctx.method === 'GET') {
try {
// 調用koa-send發送文件
// 如果發送成功,koa-send會返迴路徑,賦值給done
// done轉換為bool值就是true
done = await send(ctx, ctx.path, opts);
} catch (err) {
// 如果不是404,可能是一些400,500這種非預期的錯誤,將它拋出去
if (err.status !== 404) {
throw err
}
}
}
// 通過done來檢測文件是否發送成功
// 如果沒成功,就讓後續中間件繼續處理他
// 如果成功了,本次請求就到此為止了
if (!done) {
await next()
}
}
}
opt.defer
defer
是配置選項opt
裏面的一個可選參數,他稍微特殊一點,默認為false
,如果你傳了true
,koa-static
會讓其他中間件先響應,即使其他中間件寫在koa-static
後面也會讓他先響應,自己最後響應。要實現這個,其實就是控制調用next()
的時機。在講Koa源碼的文章裏面已經講過了,調用next()
其實就是在調用後面的中間件,所以像上面代碼那樣最後調用next()
,就是先執行koa-static
然後再執行其他中間件。如果你給defer
傳了true
,其實就是先執行next()
,然後再執行koa-static
的邏輯,按照這個思路我們來支持下defer
吧:
function serve(root, opts) {
opts = Object.assign(Object.create(null), opts);
opts.root = resolve(root);
// 如果defer為false,就用之前的邏輯,最後調用next
if (!opts.defer) {
return async function serve(ctx, next) {
let done = false;
if (ctx.method === 'HEAD' || ctx.method === 'GET') {
try {
done = await send(ctx, ctx.path, opts);
} catch (err) {
if (err.status !== 404) {
throw err
}
}
}
if (!done) {
await next()
}
}
}
// 如果defer為true,先調用next,然後執行自己的邏輯
return async function serve(ctx, next) {
// 先調用next,執行後面的中間件
await next();
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return
// 如果ctx.body有值了,或者status不是404,說明請求已經被其他中間件處理過了,就直接返回了
if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line
// koa-static自己的邏輯還是一樣的,都是調用koa-send
try {
await send(ctx, ctx.path, opts)
} catch (err) {
if (err.status !== 404) {
throw err
}
}
}
}
koa-static
源碼總共就幾十行://github.com/koajs/static/blob/master/index.js
koa-send
上面我們看到koa-static
其實是包裝的koa-send
,真正發送文件的操作都是在koa-send
裏面的。文章最開頭說的幾件事情koa-static
一件也沒幹,都丟給koa-send
了,也就是說他應該把這幾件事都幹完:
- 通過請求路徑取出正確的文件地址
- 通過地址獲取對應的文件
- 使用
Node.js
的API返回對應的文件,並設置相應的header
由於koa-send
代碼也不多,我就直接在代碼中寫注釋了,通過前面的使用,我們已經知道他的使用形式是:
send (ctx, path, opts)
他接收三個參數:
ctx
:就是koa
的那個上下文ctx
。path
:koa-static
傳過來的是ctx.path
,看過koa
源碼解析的應該知道,這個值其實就是req.path
opts
: 一些配置項,defer
前面講過了,會影響執行順序,其他還有些緩存控制什麼的。
下面直接來寫一個send
方法吧:
const fs = require('fs')
const fsPromises = fs.promises;
const { stat, access } = fsPromises;
const {
normalize,
basename,
extname,
resolve,
parse,
sep
} = require('path')
const resolvePath = require('resolve-path')
// 導出send方法
module.exports = send;
// send方法的實現
async function send(ctx, path, opts = {}) {
// 先解析配置項
const root = opts.root ? normalize(resolve(opts.root)) : ''; // 這裡的root就是我們配置的靜態文件目錄,比如public
const index = opts.index; // 請求文件夾時,會去讀取這個index文件
const maxage = opts.maxage || opts.maxAge || 0; // 就是http緩存控制Cache-Control的那個maxage
const immutable = opts.immutable || false; // 也是Cache-Control緩存控制的
const format = opts.format !== false; // format默認是true,用來支持/directory這種不帶/的文件夾請求
const trailingSlash = path[path.length - 1] === '/'; // 看看path結尾是不是/
path = path.substr(parse(path).root.length) // 去掉path開頭的/
path = decode(path); // 其實就是decodeURIComponent, decode輔助方法在後面
if (path === -1) return ctx.throw(400, 'failed to decode');
// 如果請求以/結尾,肯定是一個文件夾,將path改為文件夾下面的默認文件
if (index && trailingSlash) path += index;
// resolvePath可以將一個根路徑和請求的相對路徑合併成一個絕對路徑
// 並且防止一些常見的攻擊,比如GET /../file.js
// GitHub地址://github.com/pillarjs/resolve-path
path = resolvePath(root, path)
// 用fs.stat獲取文件的基本信息,順便檢測下文件存在不
let stats;
try {
stats = await stat(path)
// 如果是文件夾,並且format為true,拼上index文件
if (stats.isDirectory()) {
if (format && index) {
path += `/${index}`
stats = await stat(path)
} else {
return
}
}
} catch (err) {
// 錯誤處理,如果是文件不存在,返回404,否則返回500
const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
if (notfound.includes(err.code)) {
// createError來自http-errors庫,可以快速創建HTTP錯誤對象
// github地址://github.com/jshttp/http-errors
throw createError(404, err)
}
err.status = 500
throw err
}
// 設置Content-Length的header
ctx.set('Content-Length', stats.size)
// 設置緩存控制header
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 = extname(path)
ctx.body = fs.createReadStream(path)
return path
}
function decode(path) {
try {
return decodeURIComponent(path)
} catch (err) {
return -1
}
}
上述代碼並沒有太複雜的邏輯,先拼一個完整的地址,然後使用fs.stat
獲取文件的基本信息,如果文件不存在,這個API就報錯了,直接返回404
。如果文件存在,就用fs.stat
拿到的信息設置Content-Length
和一些緩存控制的header。
koa-send
的源碼也只有一個文件,百來行代碼://github.com/koajs/send/blob/master/index.js
ctx.type和ctx.body
上述代碼我們看到最後並沒有直接返迴文件,而只是設置了ctx.type
和ctx.body
這兩個值就結束了,為啥設置了這兩個值,文件就自動返回了呢?要知道這個原理,我們要結合Koa
源碼來看。
之前講Koa
源碼的時候我提到過,他擴展了Node
原生的res
,並且在裏面給type
屬性添加了一個set
方法:
set type(type) {
type = getType(type);
if (type) {
this.set('Content-Type', type);
} else {
this.remove('Content-Type');
}
}
這段代碼的作用是當你給ctx.type
設置值的時候,會自動給Content-Type
設置值,getType
其實是另一個第三方庫cache-content-type
,他可以根據你傳入的文件類型,返回匹配的MIME type
。我剛看koa-static
源碼時,找了半天也沒找到在哪裡設置的Content-Type
,後面發現是在Koa
源碼裏面。所以設置了ctx.type
其實就是設置了Content-Type
。
koa
擴展的type
屬性看這裡://github.com/koajs/koa/blob/master/lib/response.js#L308
之前講Koa
源碼的時候我還提到過,當所有中間件都運行完了,最後會運行一個方法respond
來返回結果,在那篇文章裏面,respond
是簡化版的,直接用res.end
返回了結果:
function respond(ctx) {
const res = ctx.res; // 取出res對象
const body = ctx.body; // 取出body
return res.end(body); // 用res返回body
}
直接用res.end
返回結果只能對一些簡單的小對象比較合適,比如字符串什麼的。對於複雜對象,比如文件,這個就合適了,因為你如果要用res.write
或者res.end
返迴文件,你需要先把文件整個讀入內存,然後作為參數傳遞,如果文件很大,服務器內存可能就爆了。那要怎麼處理呢?回到koa-send
源碼裏面,我們給ctx.body
設置的值其實是一個可讀流:
ctx.body = fs.createReadStream(path)
這種流怎麼返回呢?其實Node.js
對於返迴流本身就有很好的支持。要返回一個值,需要用到http
回調函數裏面的res
,這個res
本身其實也是一個流。大家可以再翻翻Node.js
官方文檔,這裡的res
其實是http.ServerResponse
類的一個實例,而http.ServerResponse
本身又繼承自Stream
類:
所以res
本身就是一個流Stream
,那Stream
的API就可以用了。ctx.body
是使用fs.createReadStream
創建的,所以他是一個可讀流,可讀流有一個很方便的API可以直接讓內容流動到可寫流:readable.pipe
,使用這個API,Node.js
會自動將可讀流裏面的內容推送到可寫流,數據流會被自動管理,所以即使可讀流更快,目標可寫流也不會超負荷,而且即使你文件很大,因為不是一次讀入內存,而是流式讀入,所以也不會爆。所以我們在Koa
的respond
裏面支持下流式body
就行了:
function respond(ctx) {
const res = ctx.res;
const body = ctx.body;
// 如果body是個流,直接用pipe將它綁定到res上
if (body instanceof Stream) return body.pipe(res);
return res.end(body);
}
Koa
源碼對於流的處理看這裡://github.com/koajs/koa/blob/master/lib/application.js#L267
總結
現在,我們可以用自己寫的koa-static
來替換官方的了,運行效果是一樣的。最後我們再來回顧下本文的要點:
-
本文是
Koa
常用靜態服務中間件koa-static
的源碼解析。 -
由於是一個
Koa
的中間件,所以koa-static
的返回值是一個方法,而且需要符合中間件範式:(ctx, next) => {}
-
作為一個靜態服務中間件,
koa-static
本應該完成以下幾件事情:- 通過請求路徑取出正確的文件地址
- 通過地址獲取對應的文件
- 使用
Node.js
的API返回對應的文件,並設置相應的header
但是這幾件事情他一件也沒幹,都扔給
koa-send
了,所以他官方文檔也說了他只是wrapper for koa-send.
-
作為一個
wrapper
他還支持了一個比較特殊的配置項opt.defer
,這個配置項可以控制他在所有Koa
中間件裏面的執行時機,其實就是調用next
的時機。如果你給這個參數傳了true
,他就先調用next
,讓其他中間件先執行,自己最後執行,反之亦然。有了這個參數,你可以將/test.jpg
這種請求先作為普通路由處理,路由沒匹配上再嘗試靜態文件,這在某些場景下很有用。 -
koa-send
才是真正處理靜態文件,他把前面說的三件事全乾了,在拼接文件路徑時還使用了resolvePath
來防禦常見攻擊。 -
koa-send
取文件時使用了fs
模塊的API創建了一個可讀流,並將它賦值給ctx.body
,同時設置了ctx.type
。 -
通過
ctx.type
和ctx.body
返回給請求者並不是koa-send
的功能,而是Koa
本身的功能。由於http
模塊提供和的res
本身就是一個可寫流,所以我們可以通過可讀流的pipe
函數直接將ctx.body
綁定到res
上,剩下的工作Node.js
會自動幫我們完成。 -
使用流(
Stream
)來讀寫文件有以下幾個優點:- 不用一次性將文件讀入內存,暫用內存小。
- 如果文件很大,一次性讀完整個文件,可能耗時較長。使用流,可以一點一點讀文件,讀到一點就可以返回給
response
,有更快的響應時間。 Node.js
可以在可讀流和可寫流之間使用管道進行數據傳輸,使用也很方便。
參考資料:
koa-static
文檔://github.com/koajs/static
koa-static
源碼://github.com/koajs/static/blob/master/index.js
koa-send
文檔://github.com/koajs/send
koa-send
源碼://github.com/koajs/send/blob/master/index.js
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。
作者博文GitHub項目地址: //github.com/dennis-jiang/Front-End-Knowledges
我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~