1. 程式人生 > >手寫koa-static原始碼,深入理解靜態伺服器原理

手寫koa-static原始碼,深入理解靜態伺服器原理

這篇文章繼續前面的`Koa`原始碼系列,這個系列已經有兩篇文章了: 1. 第一篇講解了`Koa`的核心架構和原始碼:[手寫Koa.js原始碼](https://www.cnblogs.com/dennisj/p/13947650.html) 2. 第二篇講解了`@koa/router`的架構和原始碼:[手寫@koa/router原始碼](https://www.cnblogs.com/dennisj/p/13984851.html) 本文會接著講一個常用的中介軟體----`koa-static`,這個中介軟體是用來搭建靜態伺服器的。 其實在我之前[使用Node.js原生API寫一個web伺服器](https://www.cnblogs.com/dennisj/p/13878243.html)已經講過怎麼返回一個靜態檔案了,程式碼雖然比較醜,基本流程還是差不多的: 1. 通過請求路徑取出正確的檔案地址 2. 通過地址獲取對應的檔案 3. 使用`Node.js`的API返回對應的檔案,並設定相應的`header` `koa-static`的程式碼更通用,更優雅,而且對大檔案有更好的支援,下面我們來看看他是怎麼做的吧。本文還是採用一貫套路,先看一下他的基本用法,然後從基本用法入手去讀原始碼,並手寫一個簡化版的原始碼來替換他。 **本文可執行程式碼已經上傳GitHub,大家可以拿下來玩玩:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic)** ## 基本用法 `koa-static`使用很簡單,主要程式碼就一行: ```javascript 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`資料夾下面的檔案都可以被外部訪問。比如我在裡面放了一張圖片: ![image-20201125163558774](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8c4fda8fb6bf45778632fc097cb5a8ef~tplv-k3u1fbpfcp-zoom-1.image) 跑起來就是這樣子: ![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/68057b38d7ec4d9cb867974f3ce4ba7b~tplv-k3u1fbpfcp-zoom-1.image) 注意上面這個路徑請求的是`/test.jpg`,前面並沒有`public`,說明`koa-static`對請求路徑進行了判斷,發現是檔案就對映到伺服器的`public`目錄下面,這樣可以防止外部使用者探知伺服器目錄結構。 ## 手寫原始碼 ### 返回的是一個`Koa`中介軟體 我們看到`koa-static`匯出的是一個方法`serve`,這個方法執行後返回的應該是一個`Koa`中介軟體,這樣`Koa`才能引用他,所以我們先來寫一下這個結構吧: ```javascript module.exports = serve; // 匯出的是serve方法 // serve接受兩個引數 // 第一個引數是路徑地址 // 第二個是配置選項 function serve(root, opts) { // 返回一個方法,這個方法符合koa中介軟體的定義 return async function serve(ctx, next) { await next(); } } ``` ### 呼叫`koa-send`返回檔案 現在這個中介軟體是空的,其實他應該做的是將檔案返回,返回檔案的功能也被單獨抽取出來成了一個庫----`koa-send`,我們後面會看他原始碼,這裡先直接用吧。 ```javascript 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原始碼的文章裡面已經講過了](https://www.cnblogs.com/dennisj/p/13947650.html),呼叫`next()`其實就是在呼叫後面的中介軟體,所以像上面程式碼那樣最後呼叫`next()`,就是先執行`koa-static`然後再執行其他中介軟體。如果你給`defer`傳了`true`,其實就是先執行`next()`,然後再執行`koa-static`的邏輯,按照這個思路我們來支援下`defer`吧: ```javascript 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](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`程式碼也不多,我就直接在程式碼中寫註釋了,通過前面的使用,我們已經知道他的使用形式是: ```javascript send (ctx, path, opts) ``` 他接收三個引數: 1. `ctx`:就是`koa`的那個上下文`ctx`。 2. `path`:`koa-static`傳過來的是`ctx.path`,看過`koa`原始碼解析的應該知道,這個值其實就是`req.path` 3. `opts`: 一些配置項,`defer`前面講過了,會影響執行順序,其他還有些快取控制什麼的。 下面直接來寫一個`send`方法吧: ```javascript 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](https://github.com/koajs/send/blob/master/index.js) ### ctx.type和ctx.body 上述程式碼我們看到最後並沒有直接返回檔案,而只是設定了`ctx.type`和`ctx.body`這兩個值就結束了,為啥設定了這兩個值,檔案就自動返回了呢?要知道這個原理,我們要結合`Koa`原始碼來看。 之前講`Koa`原始碼的時候我提到過,他擴充套件了`Node`原生的`res`,並且在裡面給`type`屬性添加了一個`set`方法: ```javascript 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`](https://github.com/node-modules/cache-content-type),他可以根據你傳入的檔案型別,返回匹配的`MIME type`。我剛看`koa-static`原始碼時,找了半天也沒找到在哪裡設定的`Content-Type`,後面發現是在`Koa`原始碼裡面。**所以設定了`ctx.type`其實就是設定了`Content-Type`**。 `koa`擴充套件的`type`屬性看這裡:[https://github.com/koajs/koa/blob/master/lib/response.js#L308](https://github.com/koajs/koa/blob/master/lib/response.js#L308) 之前講`Koa`原始碼的時候我還提到過,當所有中介軟體都執行完了,最後會執行一個方法`respond`來返回結果,在那篇文章裡面,`respond`是簡化版的,直接用`res.end`返回了結果: ```javascript 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`設定的值其實是一個可讀流: ```javascript ctx.body = fs.createReadStream(path) ``` 這種流怎麼返回呢?其實`Node.js`對於返回流本身就有很好的支援。要返回一個值,需要用到`http`回撥函式裡面的`res`,這個`res`本身其實也是一個流。[大家可以再翻翻`Node.js`官方文件](http://nodejs.cn/api/http.html#http_class_http_serverresponse),這裡的`res`其實是`http.ServerResponse`類的一個例項,而`http.ServerResponse`本身又繼承自`Stream`類: ![image-20201203154324281](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1e309469ce1e403592ef22413b009bfc~tplv-k3u1fbpfcp-zoom-1.image) **所以`res`本身就是一個流`Stream`,那`Stream`的API就可以用了**。`ctx.body`是使用`fs.createReadStream`建立的,所以他是一個可讀流,[可讀流有一個很方便的API可以直接讓內容流動到可寫流:`readable.pipe`](http://nodejs.cn/api/stream.html#stream_readable_pipe_destination_options),使用這個API,`Node.js`會自動將可讀流裡面的內容推送到可寫流,資料流會被自動管理,所以即使可讀流更快,目標可寫流也不會超負荷,而且即使你檔案很大,因為不是一次讀入記憶體,而是流式讀入,所以也不會爆。所以我們在`Koa`的`respond`裡面支援下流式`body`就行了: ```javascript 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](https://github.com/koajs/koa/blob/master/lib/application.js#L267) ## 總結 **本文可執行程式碼已經上傳GitHub,大家可以拿下來玩玩:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic)** 現在,我們可以用自己寫的`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.type`和`ctx.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](https://github.com/koajs/static) `koa-static`原始碼:[https://github.com/koajs/static/blob/master/index.js](https://github.com/koajs/static/blob/master/index.js) `koa-send`文件:[https://github.com/koajs/send](https://github.com/koajs/send) `koa-send`原始碼:[https://github.com/koajs/send/blob/master/index.js](https://github.com/koajs/send/blob/master/index.js) **文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。** **作者博文GitHub專案地址: [https://github.com/dennis-jiang/Front-End-Knowledges](https://github.com/dennis-jiang/Front-End-Knowledges)** **我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎