手寫koa-static源碼,深刻理解靜態服務器原理

這篇文章繼續前面的Koa源碼系列,這個系列已經有兩篇文章了:javascript

1.第一篇講解了Koa的核心架構和源碼:手寫Koa.js源碼[1]2.第二篇講解了@koa/router的架構和源碼:手寫@koa/router源碼[2]html

本文會接着講一個經常使用的中間件----koa-static,這個中間件是用來搭建靜態服務器的。前端

其實在我以前使用Node.js原生API寫一個web服務器[3]已經講過怎麼返回一個靜態文件了,代碼雖然比較醜,基本流程仍是差很少的:java

1.經過請求路徑取出正確的文件地址2.經過地址獲取對應的文件3.使用Node.js的API返回對應的文件,並設置相應的headernode

koa-static的代碼更通用,更優雅,並且對大文件有更好的支持,下面咱們來看看他是怎麼作的吧。本文仍是採用一向套路,先看一下他的基本用法,而後從基本用法入手去讀源碼,並手寫一個簡化版的源碼來替換他。git

基本用法

koa-static使用很簡單,主要代碼就一行:github

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的實例直接引用這個中間件就好了。web

serve方法支持兩個參數,第一個是靜態文件的目錄,第二個參數是一些配置項,能夠不傳。像上面的代碼serve('public')就表示public文件夾下面的文件均可以被外部訪問。好比我在裏面放了一張圖片:api

跑起來就是這樣子:promise

注意上面這個路徑請求的是/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,若是你傳了truekoa-static會讓其餘中間件先響應,即便其餘中間件寫在koa-static後面也會讓他先響應,本身最後響應。要實現這個,其實就是控制調用next()的時機。在講Koa源碼的文章裏面已經講過了[4],調用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源碼總共就幾十行:https://github.com/koajs/static/blob/master/index.js

koa-send

上面咱們看到koa-static實際上是包裝的koa-send,真正發送文件的操做都是在koa-send裏面的。文章最開頭說的幾件事情koa-static一件也沒幹,都丟給koa-send了,也就是說他應該把這幾件事都幹完:

1.經過請求路徑取出正確的文件地址2.經過地址獲取對應的文件3.使用Node.js的API返回對應的文件,並設置相應的header

因爲koa-send代碼也很少,我就直接在代碼中寫註釋了,經過前面的使用,咱們已經知道他的使用形式是:

send (ctx, path, opts)

他接收三個參數:

1.ctx:就是koa的那個上下文ctx2.pathkoa-static傳過來的是ctx.path,看過koa源碼解析的應該知道,這個值其實就是req.path3.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地址:https://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地址:https://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的源碼也只有一個文件,百來行代碼:https://github.com/koajs/send/blob/master/index.js

ctx.type和ctx.body

上述代碼咱們看到最後並無直接返回文件,而只是設置了ctx.typectx.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[5],他能夠根據你傳入的文件類型,返回匹配的MIME type。我剛看koa-static源碼時,找了半天也沒找到在哪裏設置的Content-Type,後面發現是在Koa源碼裏面。因此設置了ctx.type其實就是設置了Content-Type

koa擴展的type屬性看這裏:https://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官方文檔[6],這裏的res實際上是http.ServerResponse類的一個實例,而http.ServerResponse自己又繼承自Stream類:

因此res自己就是一個流Stream,那Stream的API就能夠用了ctx.body是使用fs.createReadStream建立的,因此他是一個可讀流,可讀流有一個很方便的API能夠直接讓內容流動到可寫流:readable.pipe[7],使用這個API,Node.js會自動將可讀流裏面的內容推送到可寫流,數據流會被自動管理,因此即便可讀流更快,目標可寫流也不會超負荷,並且即便你文件很大,由於不是一次讀入內存,而是流式讀入,因此也不會爆。因此咱們在Koarespond裏面支持下流式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源碼對於流的處理看這裏:https://github.com/koajs/koa/blob/master/lib/application.js#L267

總結

如今,咱們能夠用本身寫的koa-static來替換官方的了,運行效果是同樣的。最後咱們再來回顧下本文的要點:

1. 本文是Koa經常使用靜態服務中間件koa-static的源碼解析。

     2.因爲是一個Koa的中間件,因此koa-static的返回值是一個方法,並且須要符合中間件範式: (ctx, next) => {}

     3.做爲一個靜態服務中間件,koa-static本應該完成如下幾件事情:

1.經過請求路徑取出正確的文件地址2.經過地址獲取對應的文件3.使用Node.js的API返回對應的文件,並設置相應的header

可是這幾件事情他一件也沒幹,都扔給koa-send了,因此他官方文檔也說了他只是wrapper for koa-send.

  4.做爲一個wrapper他還支持了一個比較特殊的配置項opt.defer,這個配置項能夠控制他在全部Koa中間件裏面的執行時機,其實就是調用next的時機。若是你給這個參數傳了true,他就先調用next,讓其餘中間件先執行,本身最後執行,反之亦然。有了這個參數,你能夠將/test.jpg這種請求先做爲普通路由處理,路由沒匹配上再嘗試靜態文件,這在某些場景下頗有用。  5.koa-send纔是真正處理靜態文件,他把前面說的三件事全乾了,在拼接文件路徑時還使用了resolvePath來防護常見攻擊。  6.koa-send取文件時使用了fs模塊的API建立了一個可讀流,並將它賦值給ctx.body,同時設置了ctx.type  7.經過ctx.typectx.body返回給請求者並非koa-send的功能,而是Koa自己的功能。因爲http模塊提供和的res自己就是一個可寫流,因此咱們能夠經過可讀流的pipe函數直接將ctx.body綁定到res上,剩下的工做Node.js會自動幫咱們完成。  8.使用流(Stream)來讀寫文件有如下幾個優勢:

1.不用一次性將文件讀入內存,暫用內存小。2.若是文件很大,一次性讀完整個文件,可能耗時較長。使用流,能夠一點一點讀文件,讀到一點就能夠返回給response,有更快的響應時間。3.Node.js能夠在可讀流和可寫流之間使用管道進行數據傳輸,使用也很方便。

參考資料:

koa-static文檔:https://github.com/koajs/static

koa-static源碼:https://github.com/koajs/static/blob/master/index.js

koa-send文檔:https://github.com/koajs/send

koa-send源碼:https://github.com/koajs/send/blob/master/index.js

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

做者博文GitHub項目地址:https://github.com/dennis-jiang/Front-End-Knowledges

做者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd

我也搞了個公衆號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

References

[1] 手寫Koa.js源碼: https://juejin.cn/post/6892952604163342344
[2] 手寫@koa/router源碼: https://juejin.cn/post/6895594434843869197
[3] 使用Node.js原生API寫一個web服務器: https://juejin.cn/post/6887797543212843016
[4] 講Koa源碼的文章裏面已經講過了: https://juejin.cn/post/6892952604163342344
[5] cache-content-typehttps://github.com/node-modules/cache-content-type
[6] 你們能夠再翻翻Node.js官方文檔: http://nodejs.cn/api/http.html#http_class_http_serverresponse
[7] 可讀流有一個很方便的API能夠直接讓內容流動到可寫流:readable.pipehttp://nodejs.cn/api/stream.html#stream_readable_pipe_destination_options


本文分享自微信公衆號 - 進擊的大前端(AdvanceOnFE)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索